0%

修改Cloudreve使之支持CasDoor登录

这次来修改Cloudreve,这是一个使用golang做后端,react做前端,开发的网盘系统。

这里主要是自用,所以本着够用就行的原则,不去折腾已有代码,使之能够适配CAS登录即完成目的。

最终的思路是:前端构造CAS跳转链接,CAS授权后,code返回前端,由前端向后端发起code和state请求,后端完成登录,最大程度复用已有代码。(主要是既不会react也不熟悉golang,所以要用简单的方式完成)

前端

先修改前端,只涉及一个文件:Cloudreve/assets/src/component/Login/LoginForm.js

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);
// if (!isValidate.current.isValidate && loginCaptcha) {
// validate(() => login(e), setLoading);
// return;
// }
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
// User 用户模型
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
// GetUserByCasID 用CasID获取用户
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
// UserLoginSSO CAS用户登录
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
// UserLoginSSOService 管理用户登录的服务
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
// LoginSSO CAS用户登录函数
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}
}

//登陆成功,清空并设置session
util.SetSession(c, map[string]interface{}{
"user_id": expectedUser.ID,
})

return serializer.BuildUserResponse(expectedUser)

}

至此,大功告成。

题外话:现代前端真的好复杂….