132 lines
5.0 KiB
Go
132 lines
5.0 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"service.xpcool.com/internal/consts"
|
||
"service.xpcool.com/internal/dao"
|
||
"service.xpcool.com/internal/library/jwt"
|
||
"service.xpcool.com/internal/library/response"
|
||
"service.xpcool.com/internal/model/do"
|
||
"service.xpcool.com/internal/model/dto"
|
||
"service.xpcool.com/internal/model/entity"
|
||
)
|
||
|
||
// WechatResolver 抽象微信 code 换 openid,避免业务层依赖具体 HTTP 实现。
|
||
type WechatResolver interface {
|
||
OpenID(context.Context, string) (string, error)
|
||
}
|
||
type SMSVerifier interface {
|
||
Verify(context.Context, string, string) error
|
||
}
|
||
|
||
// userAuth 负责用户三种登录方式及令牌签发,不承担第三方平台通信细节。
|
||
type userAuth struct {
|
||
tokens *jwt.Service
|
||
wechat WechatResolver
|
||
sms SMSVerifier
|
||
}
|
||
|
||
func NewUserAuth(tokens *jwt.Service, wechat WechatResolver, sms SMSVerifier) IUserAuth {
|
||
return &userAuth{tokens, wechat, sms}
|
||
}
|
||
func (s *userAuth) Login(ctx context.Context, in dto.UserLoginInput) (*dto.TokenPair, uint64, error) {
|
||
var u entity.User
|
||
var err error
|
||
switch in.LoginType {
|
||
case "wechat":
|
||
// 微信首次授权成功后按 openid 自动创建用户。
|
||
if s.wechat == nil {
|
||
return nil, 0, response.Error(consts.CodeInternal, "wechat provider not configured")
|
||
}
|
||
openID, e := s.wechat.OpenID(ctx, in.Code)
|
||
if e != nil {
|
||
return nil, 0, gerror.Wrap(e, "wechat login")
|
||
}
|
||
err = dao.User.Ctx(ctx).Where(do.User{OpenId: openID}).Scan(&u)
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "query user")
|
||
}
|
||
if u.Id == 0 {
|
||
id, e := dao.User.Ctx(ctx).Data(do.User{OpenId: openID, Nickname: "微信用户", Status: 1}).InsertAndGetId()
|
||
if e != nil {
|
||
return nil, 0, gerror.Wrap(e, "create user")
|
||
}
|
||
u.Id = uint64(id)
|
||
}
|
||
case "mobile":
|
||
// 验证码由注入的服务校验,校验成功后按手机号自动注册。
|
||
if s.sms == nil {
|
||
return nil, 0, response.Error(consts.CodeInternal, "sms provider not configured")
|
||
}
|
||
if err = s.sms.Verify(ctx, in.Mobile, in.VerifyCode); err != nil {
|
||
return nil, 0, response.Error(consts.CodeInvalidParam, "invalid verification code")
|
||
}
|
||
err = dao.User.Ctx(ctx).Where(do.User{Mobile: in.Mobile}).Scan(&u)
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "query user")
|
||
}
|
||
if u.Id == 0 {
|
||
id, e := dao.User.Ctx(ctx).Data(do.User{Mobile: in.Mobile, Nickname: "用户" + in.Mobile[7:], Status: 1}).InsertAndGetId()
|
||
if e != nil {
|
||
return nil, 0, gerror.Wrap(e, "create user")
|
||
}
|
||
u.Id = uint64(id)
|
||
}
|
||
case "password":
|
||
// 密码只使用 bcrypt 比对哈希值,任何场景都不回传或记录明文。
|
||
err = dao.User.Ctx(ctx).Where(do.User{Account: in.Account}).Scan(&u)
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "query user")
|
||
}
|
||
if u.Id == 0 || bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
|
||
return nil, 0, response.Error(consts.CodeUserPasswordWrong, "account or password incorrect")
|
||
}
|
||
default:
|
||
return nil, 0, response.Error(consts.CodeUserLoginType, "unsupported login type")
|
||
}
|
||
if u.Status != 1 {
|
||
return nil, 0, response.Error(consts.CodeForbidden, "user disabled")
|
||
}
|
||
_, err = dao.User.Ctx(ctx).Where(do.User{Id: u.Id}).Data(do.User{LastLoginAt: gtime.Now()}).Update()
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "update login time")
|
||
}
|
||
return s.issue(ctx, u.Id, in.Terminal)
|
||
}
|
||
func (s *userAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair, uint64, error) {
|
||
// 刷新时先校验 JWT,再以 JTI 原子撤销旧会话,实现单次使用的令牌轮换。
|
||
c, err := s.tokens.Parse(refresh, "refresh", "user")
|
||
if err != nil {
|
||
return nil, 0, response.Error(consts.CodeUnauthorized, "invalid refresh token")
|
||
}
|
||
result, err := dao.AuthRefreshSession.Ctx(ctx).Where(do.AuthRefreshSession{Jti: c.JTI}).WhereNull("revoked_at").Data(do.AuthRefreshSession{RevokedAt: gtime.Now()}).Update()
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "撤销旧刷新令牌失败")
|
||
}
|
||
affected, err := result.RowsAffected()
|
||
if err != nil || affected != 1 {
|
||
return nil, 0, response.Error(consts.CodeUnauthorized, "刷新令牌已失效")
|
||
}
|
||
return s.issue(ctx, c.Subject, c.Terminal)
|
||
}
|
||
|
||
// issue 签发 JWT 后把 refresh token 的 JTI 落库,确保可以撤销和追踪设备会话。
|
||
func (s *userAuth) issue(ctx context.Context, id uint64, terminal string) (*dto.TokenPair, uint64, error) {
|
||
a, r, exp, err := s.tokens.Issue(id, "user", terminal)
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "issue token")
|
||
}
|
||
claims, err := s.tokens.Parse(r, "refresh", "user")
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "解析新刷新令牌失败")
|
||
}
|
||
_, err = dao.AuthRefreshSession.Ctx(ctx).Data(do.AuthRefreshSession{SubjectId: id, Scope: "user", Terminal: terminal, Jti: claims.JTI, ExpiredAt: gtime.NewFromTimeStamp(claims.ExpireAt)}).Insert()
|
||
if err != nil {
|
||
return nil, 0, gerror.Wrap(err, "保存刷新令牌会话失败")
|
||
}
|
||
return &dto.TokenPair{AccessToken: a, RefreshToken: r, ExpiresIn: exp}, id, nil
|
||
}
|