微信扫码登录实现
登录流程
总体来说,就是三步:
- 点击微信登录,跳转到微信页面
- 微信扫码登录,确认登陆
- 微信跳转回来
这里,我们就得,明确两个问题:
- 跳到微信界面,跳过去的 URL 是什么?
- 跳转回来的 URL 是什么?
这些我们就得从微信给我们提供的 API 出发了。
微信登录 API
微信扫码登录其实是一个 OAuth2 授权过程。简单的说,即使你作为用户授权第三方应用获得了对应的access_token
, 第三方应用就认为你登录了。
在大多数场景下,第一次登录的时候还会尝试回去用户的信息。
从上面的图可以看出来,我们需要构建一个 URL,里面要携带上一些参数:
- appid:在微信开放平台注册的 ID
- redirect_uri:微信扫码登录后跳转回来的地址
- response_type:固定为 code
- scope:应用授权作用域,拥有多个作用域用逗号(,)分隔,网页应用目前仅填写snsapi_login
- state:用于保持请求和回调的状态,授权请求后原样带回给第三方。该参数可用于防止csrf攻击(跨站请求伪造攻击),建议第三方带上该参数,可设置为简单的随机数加session进行校验
用户允许授权后,将会重定向到redirect_uri的网址上,并且带上code和state参数。
redirect_uri?code=CODE&state=STATE
如果用户禁止授权,则会阻止重定向。
从微信跳转回来后,会携带上一个 code
,我们要用这个 code
去微信里面换取一个access_token
。也是一个发起调用的过程,这里就需要传入appid
和 secret
。
要点总结
要想实现微信登录,必须经过两次跳转:
- 点击微信登录,跳转到微信页面。跳转过去的地址是微信的扫码地址,根据要求必须携带上
redirect_uri
,appid
和state
三个属性 - 微信扫码登录,确认登陆,从微信跳转回来。跳转回来的地址就是
redirect_uri
的地址。微信此时会携带上临时的授权码code
- 后台处理
redirect_uri
中带过来的code
,找微信换取真正的长时间有效的授权码
设计过程
接口抽象
经过前面的分析,我们应该明确,需要两个接口:
第一个接口:用于构建跳转到微信的 URL
第二个接口:处理微信跳转回来的请求
func (h *OAuth2WeChatHandler) RegisterRoutes(r *gin.Engine) {we := r.Group("/oauth2/wechat")we.GET("/authurl", h.AuthURL)we.Any("/callback", h.Callback)
}
构造URL
构造URL,实质上是拼接字符串的过程,我们需要定义一个 wechat.Service
,并且提供实现。
再实现的过程中,我们使用 shortuuid
来代替 state
字短,因为我们还不知道state
现在有什么用途。
var redirectUrl = url.PathEscape("https://test.com/oauth2/wechat/callback")func (ws *service) AuthURL(ctx context.Context) (string, error) {const urlPattern = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_userinfo&state=%s#wechat_redirect"state := uuid.New()return fmt.Sprintf(urlPattern, ws.appId, redirectUrl, state), nil
}
用户 Handler
的实现。
func (h *OAuth2WeChatHandler) AuthURL(ctx *gin.Context) {url, err := h.svc.AuthURL(ctx)if err != nil {ctx.JSON(http.StatusOK, Result{Code: 5,Msg: "get auth url failed",})}ctx.JSON(http.StatusOK, Result{Code: 2,Msg: url,})
}
现在扫码之后,我们就可以跳转回需要登陆的页面了。接下来,就考虑怎么进行验证 code
。
从微信回调回来的 code
是一个临时的授权码,所以还需要调用微信的接口,获得真正的授权码。(本质上也是调用一个 API)
func (ws *service) VerifyCode(ctx context.Context, code string, state string) (domain.WechatInfo, error) {const targetPattern = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code"target := fmt.Sprintf(targetPattern, ws.appId, ws.appSecret, code)req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)if err != nil {return domain.WechatInfo{}, err}resp, err := ws.client.Do(req)if err != nil {return domain.WechatInfo{}, err}decoder := json.NewDecoder(resp.Body)var result Resulterr = decoder.Decode(&result)if err != nil {return domain.WechatInfo{}, err}if result.ErrCode != 0 {return domain.WechatInfo{}, fmt.Errorf("wechat auth error: %s", result.ErrMsg)}return domain.WechatInfo{OpenId: result.OpenID,UnionId: result.UnionID,}, nil
}
返回字段
当微信校验通过之后,我们会拿到一个 Result
结构,具体这个结构体都有哪些字段呢?
关键的两部分为:授权码和 ID。
- 授权码部分:
- access_token:后面我们可以拿着
access_token
去访问微信,获取用户的数据 - expires_in:
access_token
的有效期 - refresh_token: 当
access_token
过期之后,我们可以拿着refresh_token
去找微信换一个新的access_token
。
- access_token:后面我们可以拿着
注意,使用 access_token
和 refresh_token
是一个典型的长短token实现。
access_token
是短token,refresh_token
是长token。
- ID 部分:
- open_id: 在这个应用下为一的 ID
- union_id: 在这个公司下唯一的 ID
假设你的公司在微信公众平台上注册了两个产品:A 和 B。
对于某一个用户张三来说,他在A上有一个open_id
,在B上也有一个open_id
。
但是张三在你们公司的A和B两个产品的union_id
都是同一个。
type Result struct {ErrCode int64 `json:"errcode"`ErrMsg string `json:"errmsg"`AccessToken string `json:"access_token"`ExpiresIn int64 `json:"expires_in"`RefreshToken string `json:"refresh_token"`OpenID string `json:"openid"`Scope string `json:"scope"`UnionID string `json:"unionid"`
}
优化登陆
这个类似于我们之前的短信登陆案例。
- 如果用户第一次登陆,我们就注册一个新的账号
- 如果用户不是第一次登陆,就直接设置一个 JWT token
用户Handler
接口的实现。
func (h *OAuth2WeChatHandler) Callback(ctx *gin.Context) {code := ctx.Query("code")state := ctx.Query("state")info, err := h.svc.VerifyCode(ctx, code, state)if err != nil {ctx.JSON(http.StatusOK, Result{Code: 5,Msg: "verify code failed",})}u, err := h.userSvc.FindOrCreateByWechat(ctx, info)if err != nil {ctx.JSON(http.StatusOK, Result{Code: 5,Msg: "system error",})}err = h.setJWTToken(ctx, u.Id)if err != nil {ctx.JSON(http.StatusOK, Result{Code: 5,Msg: "set token failed",})}ctx.JSON(http.StatusOK, Result{Code: 2,Msg: "success",})
}
实现 service
层上的函数。
func (svc *userService) FindOrCreateByWechat(ctx *gin.Context, info domain.WechatInfo) (domain.User, error) {u, err := svc.repo.FindByWechatOpenId(ctx, info.OpenId)if err != repository.ErrUserNotFound {return u, err}u = domain.User{WechatInfo: info,}err = svc.repo.Create(ctx, u)if err != nil || err == repository.ErrUserDuplicate {return u, err}return svc.repo.FindByWechatOpenId(ctx, info.OpenId)
}
实现 repository
层上的函数。
func (ur *CacheUserRepository) FindByWechatOpenId(ctx context.Context, OpenId string) (domain.User, error) {u, err := ur.dao.FindByWechatOpenId(ctx, OpenId)if err != nil {return domain.User{}, err}return ur.toDomainUser(u), nil
}
对于领域对象的设计。
type WechatInfo struct {OpenId string `json:"openid"`UnionId string `json:"unionid"`
}// User 领域对象,可以理解为 DDD 中的 entity
type User struct {Id int64Email stringPassword string//Ctime time.TimeNickName stringBirthday time.TimeIntroduction stringWechatInfo WechatInfoPhone string
}
预防 CROS 攻击
这里我们主要是想让大家理解如何使用 state
。
理解 state
的核心是抓住攻击者让你使用他的临时授权码来登录账号。
具体步骤为:
- 攻击者首先会弄出来一个绑定微信的临时授权码。
- 正常用户登录成功
- 攻击者伪造一个页面,诱导用户点击,攻击者带着正常用户的Cookie(过或者JWT token)去请求,攻击者的临时授权码去绑定。
结果是什么呢?攻击者可以通过微信扫码登录成功,看到正常用户的数据信息。
那么我们如何解决这个问题呢?
整体思路就是:
- 当生成
AuthURL
的时候,我们标识一下这一次的会话,将state
和这一次的请求绑定在一起。 - 等到回调回来的时候,我们看看回调中的
state
是不是我们生成时候用的state
。
因为在整个系统中,我们使用的JWT来做身份验证的,所以这里依旧使用JWT。
使用JWT的好处是,直接用JWT里面的state
和回调过来的state
比较就可以了,不需要存储到 redis 中。
我们这里放在Cookie中,因为微信回来的时候直接经过后端,并未经过前端。
设置 State
到Cookie中。
func (h *OAuth2WeChatHandler) SetStateCookie(ctx *gin.Context, state string) error {token := jwt.NewWithClaims(jwt.SigningMethodHS256, StateClaims{State: state,RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute * 10)),},})tokenStr, err := token.SignedString(h.stateKey)if err != nil {return err}ctx.SetCookie("jwt-state", tokenStr, 600, "/oauth2/wechat/callback", "",h.cfg.Secure, true)return nil
}
校验 State
字短。
整个校验的过程就是我们从 JWT 中拿到我们存储的 state
,然后进行比较。
func (h *OAuth2WeChatHandler) VerifyState(ctx *gin.Context) error {state := ctx.Query("state")ck, err := ctx.Cookie("jwt-state")if err != nil {return fmt.Errorf("get cookie failed: %w", err)}var sc StateClaimstoken, err := jwt.ParseWithClaims(ck, &sc, func(token *jwt.Token) (interface{}, error) {return h.stateKey, nil})if err != nil || !token.Valid {return fmt.Errorf("verify state failed: %w", err)}if state != sc.State {return errors.New("state not match")}return nil
}
那么就有人会问了,你这还是会有 CORS 的问题呀。但是 Cookie泄漏是一件比较困难的事情,一般是你电脑本身中病毒了,单纯的跨域攻击是没办法拿到的。
这里,我们向大家讲述了如何进行微信扫码登录,并且介绍如何使用state
字段。
绝大部分的公司都没有处理 state
,所以你要解释清楚,什么情况下不处理state
会造成跨域问题,以及如何解决的问题。