这次来修改Cloudreve,这是一个使用golang做后端,react做前端,开发的网盘系统。
这里主要是自用,所以本着够用就行的原则,不去折腾已有代码,使之能够适配CAS登录即完成目的。
最终的思路是:前端构造CAS跳转链接,CAS授权后,code返回前端,由前端向后端发起code和state请求,后端完成登录,最大程度复用已有代码。(主要是既不会react也不熟悉golang,所以要用简单的方式完成)
前端
先修改前端,只涉及一个文件:Cloudreve/assets/src/component/Login/LoginForm.js
在login函数中,我们修改一下POST的接口,并改为向后端传送code和state。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| const login = (e) => { e.preventDefault(); setLoading(true); API.post("/user/sso_callback", { code: query.get("code"), state: query.get("state"), }) .then((response) => { setLoading(false); if (response.rawData.code === 203) { setTwoFA(true); } else { afterLogin(response.data); } }) .catch((error) => { setLoading(false); ToggleSnackbar("top", "right", error.message, "warning"); captchaRefreshRef.current(); }); };
|
然后修改登陆表单,将电子邮箱和密码框的必填属性去掉。这样在返回login界面code之后,直接点击登录即可完成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <FormControl margin="normal" fullWidth> <InputLabel htmlFor="email"> 电子邮箱 </InputLabel> <Input id="email" type="email" name="email" onChange={(e) => setEmail(e.target.value) } autoComplete value={email} autoFocus /> </FormControl>
|
最后在底部,去掉注册和找回密码的链接,加上CAS的链接即可。这里直接手工构造登录URL了
1 2 3 4 5 6 7 8
| <Divider /> <div className={classes.link}> <div> <Link href={"https://your-cas-endpoint/login/oauth/authorize?client_id=your-client-id&response_type=code&redirect_uri=https%3A%2F%2Fyour-cloudreve-url%2Flogin&scope=read&state=your-app-name-in-cas"}>CAS登录</Link> </div> </div>
<ICPFooter />
|
后端
接下来主要的工作来到了后端这边,我们仿照login,构造一个SSOLogin的方法。
Cloudreve/routers/router.go
先来修改路由,加一行即可:
1 2
| user.POST("session", middleware.CaptchaRequired("login_captcha"), controllers.UserLogin) user.POST("sso_callback", middleware.CaptchaRequired("login_captcha"), controllers.UserLoginSSO)
|
DB & Cloudreve/models/user.go
这里是修改数据结构,在User类中加上casid信息。
对于数据库,我们直接在User表加一列casid即可,然后对应的模型要进行修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| type User struct { gorm.Model Email string `gorm:"type:varchar(100);unique_index"` Nick string `gorm:"size:50"` Password string `json:"-"` Status int GroupID uint Storage uint64 TwoFactor string Avatar string Options string `json:"-" gorm:"type:text"` Authn string `gorm:"type:text"` casid string
Group Group `gorm:"save_associations:false:false"` Policy Policy `gorm:"PRELOAD:false,association_autoupdate:false"`
OptionsSerialized UserOption `gorm:"-"` }
|
还要添加一个通过CasID取回用户的函数:
1 2 3 4 5 6
| func GetUserByCasID(email string) (User, error) { var user User result := DB.Set("gorm:auto_preload", true).Where("casid = ?", email).First(&user) return user, result.Error }
|
Cloudreve/routers/controllers/user.go
然后修改控制器,添加一个函数即可,对应路由中的调用:
1 2 3 4 5 6 7 8 9 10
| func UserLoginSSO(c *gin.Context) { var service user.UserLoginSSOService if err := c.ShouldBindJSON(&service); err == nil { res := service.LoginSSO(c) c.JSON(200, res) } else { c.JSON(200, ErrorResponse(err)) } }
|
Cloudreve/service/user/login.go
最后就是service的内容了,主要逻辑在这里处理。
先引入两个包,用于读取密钥以及Casdoor的SDK:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import ( "fmt" model "github.com/cloudreve/Cloudreve/v3/models" "github.com/cloudreve/Cloudreve/v3/pkg/cache" "github.com/cloudreve/Cloudreve/v3/pkg/email" "github.com/cloudreve/Cloudreve/v3/pkg/hashid" "github.com/cloudreve/Cloudreve/v3/pkg/serializer" "github.com/cloudreve/Cloudreve/v3/pkg/util" "github.com/gin-gonic/gin" "github.com/pquerna/otp/totp" "net/url" "github.com/casdoor/casdoor-go-sdk/auth" "io/ioutil" )
|
仿照UserLoginService结构体构造一个SSOLogin的结构体:
1 2 3 4 5
| type UserLoginSSOService struct { Code string `form:"code" json:"code" binding:"required"` State string `form:"state" json:"state" binding:"required"` }
|
最后仿照Login函数构造一个SSOLogin的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| func (service *UserLoginSSOService) LoginSSO(c *gin.Context) serializer.Response { JwtPublicKey, err := ioutil.ReadFile("your-certification-file") if err != nil { return serializer.Err(serializer.CodeCredentialInvalid, "读取JWT KEY错误", err) } auth.InitConfig("https://your-cas-endpoint", "your-client-id", "your-client-secret", string(JwtPublicKey), "your-cas-orgname", "your-app-name-in-cas") code := service.Code state := service.State token, err := auth.GetOAuthToken(code, state) if err != nil { return serializer.Err(serializer.CodeCredentialInvalid, "GetOAuthToken错误,请重新使用Cas登录", err) } claims, err := auth.ParseJwtToken(token.AccessToken) if err != nil { return serializer.Err(serializer.CodeCredentialInvalid, "ParseJwtToken错误", err) }
expectedUser, err := model.GetUserByCasID(claims.User.Id) if err != nil { return serializer.Err(serializer.CodeCredentialInvalid, "CAS用户不存在", err) } if expectedUser.Status == model.Baned || expectedUser.Status == model.OveruseBaned { return serializer.Err(403, "该账号已被封禁", nil) } if expectedUser.Status == model.NotActivicated { return serializer.Err(403, "该账号未激活", nil) }
if expectedUser.TwoFactor != "" { util.SetSession(c, map[string]interface{}{ "2fa_user_id": expectedUser.ID, }) return serializer.Response{Code: 203} }
util.SetSession(c, map[string]interface{}{ "user_id": expectedUser.ID, })
return serializer.BuildUserResponse(expectedUser)
}
|
至此,大功告成。
题外话:现代前端真的好复杂….