书接上文,本篇记录一下如何在项目中使用助手鉴权

例子是我正在开发的面试系统,框架是 go-zero

示例地址: https://github.com/hduhelp/interview_backend/tree/9c05efb6a8614f79871876c0376a8d2cc123ceed

(后面重构了,结构可能有变化)

(私有仓库,需杭助内部身份)

先来看 API 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
syntax = "v1"

// 目前仅测试杭助登录
service user {
@handler loginJump
get /login/jump (loginJumpRequest) // 直接跳到杭助登录

@handler LoginCallback
get /login/callback (LoginCallbackRequest) // 杭助登录回调

}

// 杭助登录回调
type (
loginJumpRequest {
From string `form:"from,optional"`
}

LoginCallbackRequest {
Code string `form:"code"` // 杭助登录返回的code
State string `form:"state"` // 杭助登录返回的state
}
)

可以看见,有两个路由:

  • /login/jump

    构造 URL 直接跳转杭助鉴权

    可选的 From 参数,用于记录从哪跳过来的,可以登录之后跳过去

  • /login/callback

    接收杭助登录回来的路由

    拿参数去请求杭助 token

    再拿凭据去请求学生信息,如果成功就算登录成功

    判断该学生属于哪个用户类型,并构造 JWT token

    然后携 JWT token跳转至前端登录路由,前端拿到 JWT token


再来看配置文件

yaml 配置文件也是要加一些东西的,包括路由和票据,这样下面就可以轻松地读取了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Name: user
Host: 0.0.0.0
Port: 8000

# 前端与后端路由配置
Routes:
Frontend: https://frontend.interview.hduhelp.com
Backend: https://backend.interview.hduhelp.com

# 杭助鉴权票据
Ticket:
ClientId:
ClientSecret:

# JWT 配置
Auth:
AccessSecret: ThisIsMySecret
AccessExpire: 1296000 # 15 天

当然啦, config.go 也是要加上的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package config

import "github.com/zeromicro/go-zero/rest"

type Config struct {
rest.RestConf
Auth struct {
AccessSecret string
AccessExpire int64
}
Routes struct {
Frontend string
Backend string
}
Ticket struct {
ClientId string
ClientSecret string
}
}


handler 层

这个 go-zero 并没有能在 API 文件中定义跳转行为的语法,所以要自己去路由层编辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func loginJumpHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.LoginJumpRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.Error(w, err)
return
}

l := logic.NewLoginJumpLogic(r.Context(), svcCtx)
redirectUrl, err := l.LoginJump(&req) // 魔改返回值
if err != nil {
httpx.Error(w, err)
} else {
//httpx.Ok(w)
http.Redirect(w, r, redirectUrl, http.StatusFound) // 魔改,直接跳转
}
}
}

另外一个也是一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func LoginCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.LoginCallbackRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.Error(w, err)
return
}

l := logic.NewLoginCallbackLogic(r.Context(), svcCtx)
redirectUrl, err := l.LoginCallback(&req)
if err != nil {
httpx.Error(w, err)
} else {
//httpx.Ok(w)
http.Redirect(w, r, redirectUrl, http.StatusFound) // 魔改,直接跳转
}
}
}

/login/jump logic

这个算是比较好理解的,只需要一个 loginjumplogic.go

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
// LoginJump 这东西魔我改了函数签名,返回值加了一个 string,指的是跳转的 url
// 构造 URL 直接跳转杭助鉴权
func (l *LoginJumpLogic) LoginJump(req *types.LoginJumpRequest) (string, error) {

if req.From == "" {
req.From = l.svcCtx.Config.Routes.Frontend + "/#/login"
}

//TODO: 把 from 存入 redis,用于鉴权后跳转

query := url.Values{}
query.Add("response_type", "code")
query.Add("client_id", l.svcCtx.Config.Ticket.ClientId)
query.Add("redirect_uri", l.svcCtx.Config.Routes.Backend+"/login/callback")
query.Add("state", svc.NewState()) // 这个就是一个 uuid,然后放在redis里面

redirectUrl := url.URL{
Scheme: "https",
Host: "api.hduhelp.com",
Path: "/oauth/authorize",
RawQuery: query.Encode(),
}

return redirectUrl.String(), nil
}

附:servicecontext.go

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
48
49
50
51
52
package svc

import (
"github.com/zeromicro/go-zero/zrpc"
"interview_backend/0_user/api/internal/config"
"interview_backend/0_user/api/internal/types"
"interview_backend/0_user/rpc/userclient"
)

type ServiceContext struct {
Config config.Config
UserRpc userclient.User
}

func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
UserRpc: userclient.NewUser(zrpc.MustNewClient(c.UserRpc)),
}
}

// NewState 生成一个随机的 state,Callback 的时候检查是不是这个 state
func NewState() string {

return "This_is_a_new_session"

// TODO: 配合 redis 生成一个新的session,就像下面这样
//sid := uuid.NewV4().String()
//err := redis.GetRedis().Set(sid, "oauth_state", time.Minute*10)
//if err != nil {
// logger.Error.Println(err)
// return ""
//}
//err = redis.GetRedis().Set(sid+"_redirect", redirect, time.Minute*10)
//if err != nil {
// logger.Error.Println(err)
// return ""
//}
//return sid
}

// CheckState 检查 state 是否在 redis 中
func CheckState(state string) (bool, error) {
//TODO:在这里 state 是否在 redis 中
return true, nil
}

// GetUserType 通过学生信息获取用户类型
func GetUserType(types.GetStudentInfoResponse) (int32, error) {
//TODO:调用RPC,根据学号判断用户类型
return 1, nil
}

/login/callback logic

这个需要向杭助请求 token 和学生信息之类的,所以我决定抽离一下服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.
├── etc
│   ├── user.yaml
│   └── user.yaml.example
├── internal
│   ├── config
│   │   └── config.go
│   ├── handler
│   │   ├── logincallbackhandler.go
│   │   ├── loginjumphandler.go
│   │   └── routes.go
│   ├── logic
│   │   ├── hduHelpService.go # 杭助请求函数
│   │   ├── logincallbacklogic.go # 主要逻辑
│   │   └── loginjumplogic.go
│   ├── middleware
│   ├── svc
│   │   └── servicecontext.go
│   └── types
│   ├── hduHelpServiceTypes.go # 杭助请求与返回体
│   └── types.go
├── user.api
└── user.go

hduHelpService.go

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package logic

import (
"errors"
"fmt"
"github.com/parnurzeal/gorequest"
"interview_backend/0_user/api/internal/types"
"net/http"
"net/url"
"time"
)

// GetStudentInfo 从 SalmonBase 获取学生信息
func (l *LoginCallbackLogic) GetStudentInfo(token string) (types.GetStudentInfoResponse, error) {
reqUrl := url.URL{
Scheme: "https",
Host: "api.hduhelp.com",
Path: "/salmon_base/student/info",
}
res := types.GetStudentInfoResponse{}
_, _, err := gorequest.New().
Get(reqUrl.String()).
Retry(3, time.Second, http.StatusBadRequest, http.StatusInternalServerError).
AppendHeader("Authorization", "token "+token).EndStruct(&res)
if err != nil {
return res, errors.New(fmt.Sprintf("%v", err))
} else {
return res, nil
}
}

// GetToken 从 api.hduhelp.com 获取 token
func (l *LoginCallbackLogic) GetToken(code, state string) (string, error) {
query := make(url.Values)
query.Add("client_id", l.svcCtx.Config.Ticket.ClientId)
query.Add("client_secret", l.svcCtx.Config.Ticket.ClientSecret)
query.Add("grant_type", "authorization_code")
query.Add("code", code)
query.Add("state", state)

reqUrl := url.URL{
Scheme: "https",
Host: "api.hduhelp.com",
Path: "/oauth/token",
RawQuery: query.Encode(),
}

fmt.Println(reqUrl.String())

res := types.GetTokenResponse{}
_, _, err := gorequest.New().
Get(reqUrl.String()).
Retry(3, time.Second, http.StatusBadRequest, http.StatusInternalServerError).
EndStruct(&res)

if err != nil {
return res.Data.AccessToken, errors.New(fmt.Sprintf("%v", err))
} else if res.Error != 0 {
return res.Data.AccessToken, errors.New(fmt.Sprintf("%v", res.Msg))
} else {
return res.Data.AccessToken, nil
}
}

hduHelpServiceTypes.go

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
package types

type HduhelpBaseResponse struct {
Cache bool `json:"cache"`
Error int `json:"error"`
Msg string `json:"msg"`
}

type GetStudentInfoResponse struct {
HduhelpBaseResponse
Data struct {
ClassId string `json:"classId"`
MajorId string `json:"majorId"`
MajorName string `json:"majorName"`
StaffId string `json:"staffId"`
StaffName string `json:"staffName"`
TeacherId string `json:"teacherId"`
TeacherName string `json:"teacherName"`
UnitId string `json:"unitId"`
UnitName string `json:"unitName"`
} `json:"data"`
}

type GetTokenResponse struct {
HduhelpBaseResponse
Data struct {
AccessToken string `json:"access_token,omitempty"`
AccessTokenExpire int64 `json:"access_token_expire,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
RefreshTokenExpire int64 `json:"refresh_token_expire,omitempty"`
StaffId string `json:"staff_id,omitempty"`
} `json:"data"`
}

最后就是 logincallbacklogic.go

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// newJwtToken 生成一个 jwt token
func (l *LoginCallbackLogic) newJwtToken(userType uint8, studentId string, studentName string) (string, error) {
claims := make(jwt.MapClaims)
claims["studentId"] = studentId
claims["studentName"] = studentName
claims["userType"] = userType

// 添加过期时间
claims["exp"] = time.Now().Add(time.Second * time.Duration(l.svcCtx.Config.Auth.AccessExpire)).Unix()

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(l.svcCtx.Config.Auth.AccessSecret))
}

// LoginCallback 接收杭助登录回来的路由
// 拿凭据去请求学生信息,如果成功就算登录成功
// 判断该学生属于哪个用户类型
// 然后携token跳转至前端登录路由,前端拿到token
// 这东西我魔改了函数签名,返回值加了一个 string
func (l *LoginCallbackLogic) LoginCallback(req *types.LoginCallbackRequest) (string, error) {
var err error

// 检查 state
if ok, err := svc.CheckState(req.State); err != nil || !ok {
return "", errors.New("state error")
}
fmt.Println("state ok")

// 尝试拿到 token
var token string
if token, err = l.GetToken(req.Code, req.State); err != nil {
return "", err
}
fmt.Println("get token ok")

// 尝试拿到学生信息
var studentInfo types.GetStudentInfoResponse
if studentInfo, err = l.GetStudentInfo(token); err != nil {
return "", err
}
fmt.Println("get student info ok")

// 判断该学生属于哪个用户类型
var userType int32
if userType, err = svc.GetUserType(studentInfo); err != nil {
return "", err
}
fmt.Println("get user type ok")

// 生成一个 jwt token
var jwtToken string
if jwtToken, err = l.newJwtToken(uint8(userType), studentInfo.Data.StaffId, studentInfo.Data.StaffName); err != nil {
return "", err
}
fmt.Println("new jwt token ok")

var redirectUrl = l.svcCtx.Config.Routes.Frontend + "/#/login"
// TODO:从 redis 中拿到 redirectUrl

return redirectUrl + "?token=" + jwtToken, nil
}

成果演示

Peek 2022-10-13 12-08