service.xpcool.com/internal/service/user_auth.go

132 lines
5.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}