| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595 |
- package service
- import (
- "context"
- "encoding/json"
- "net/http"
- "strings"
- "time"
- "cmr-backend/internal/apperr"
- "cmr-backend/internal/platform/jwtx"
- "cmr-backend/internal/platform/security"
- "cmr-backend/internal/platform/wechatmini"
- "cmr-backend/internal/store/postgres"
- )
- type AuthSettings struct {
- AppEnv string
- RefreshTTL time.Duration
- SMSCodeTTL time.Duration
- SMSCodeCooldown time.Duration
- SMSProvider string
- DevSMSCode string
- WechatMini *wechatmini.Client
- }
- type AuthService struct {
- cfg AuthSettings
- store *postgres.Store
- jwtManager *jwtx.Manager
- }
- type SendSMSCodeInput struct {
- CountryCode string `json:"countryCode"`
- Mobile string `json:"mobile"`
- ClientType string `json:"clientType"`
- DeviceKey string `json:"deviceKey"`
- Scene string `json:"scene"`
- }
- type SendSMSCodeResult struct {
- TTLSeconds int64 `json:"ttlSeconds"`
- CooldownSeconds int64 `json:"cooldownSeconds"`
- DevCode *string `json:"devCode,omitempty"`
- }
- type LoginSMSInput struct {
- CountryCode string `json:"countryCode"`
- Mobile string `json:"mobile"`
- Code string `json:"code"`
- ClientType string `json:"clientType"`
- DeviceKey string `json:"deviceKey"`
- }
- type LoginWechatMiniInput struct {
- Code string `json:"code"`
- ClientType string `json:"clientType"`
- DeviceKey string `json:"deviceKey"`
- }
- type BindMobileInput struct {
- UserID string `json:"-"`
- CountryCode string `json:"countryCode"`
- Mobile string `json:"mobile"`
- Code string `json:"code"`
- ClientType string `json:"clientType"`
- DeviceKey string `json:"deviceKey"`
- }
- type RefreshTokenInput struct {
- RefreshToken string `json:"refreshToken"`
- ClientType string `json:"clientType"`
- DeviceKey string `json:"deviceKey"`
- }
- type LogoutInput struct {
- RefreshToken string `json:"refreshToken"`
- UserID string `json:"-"`
- }
- type AuthUser struct {
- ID string `json:"id"`
- PublicID string `json:"publicId"`
- Status string `json:"status"`
- Nickname *string `json:"nickname,omitempty"`
- AvatarURL *string `json:"avatarUrl,omitempty"`
- }
- type AuthTokens struct {
- AccessToken string `json:"accessToken"`
- AccessTokenExpiresAt string `json:"accessTokenExpiresAt"`
- RefreshToken string `json:"refreshToken"`
- RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt"`
- }
- type AuthResult struct {
- User AuthUser `json:"user"`
- Tokens AuthTokens `json:"tokens"`
- NewUser bool `json:"newUser"`
- }
- func NewAuthService(cfg AuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *AuthService {
- return &AuthService{
- cfg: cfg,
- store: store,
- jwtManager: jwtManager,
- }
- }
- func (s *AuthService) SendSMSCode(ctx context.Context, input SendSMSCodeInput) (*SendSMSCodeResult, error) {
- input.CountryCode = normalizeCountryCode(input.CountryCode)
- input.Mobile = normalizeMobile(input.Mobile)
- input.Scene = normalizeScene(input.Scene)
- if err := validateClientType(input.ClientType); err != nil {
- return nil, err
- }
- if input.Mobile == "" || input.DeviceKey == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required")
- }
- latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, input.ClientType, input.Scene)
- if err != nil {
- return nil, err
- }
- now := time.Now().UTC()
- if latest != nil && latest.CooldownUntil.After(now) {
- return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently")
- }
- code := s.cfg.DevSMSCode
- if code == "" {
- code, err = security.GenerateNumericCode(6)
- if err != nil {
- return nil, err
- }
- }
- expiresAt := now.Add(s.cfg.SMSCodeTTL)
- cooldownUntil := now.Add(s.cfg.SMSCodeCooldown)
- if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{
- Scene: input.Scene,
- CountryCode: input.CountryCode,
- Mobile: input.Mobile,
- ClientType: input.ClientType,
- DeviceKey: input.DeviceKey,
- CodeHash: security.HashText(code),
- ProviderName: s.cfg.SMSProvider,
- ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider},
- ExpiresAt: expiresAt,
- CooldownUntil: cooldownUntil,
- }); err != nil {
- return nil, err
- }
- result := &SendSMSCodeResult{
- TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()),
- CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()),
- }
- if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") {
- result.DevCode = &code
- }
- return result, nil
- }
- func (s *AuthService) LoginSMS(ctx context.Context, input LoginSMSInput) (*AuthResult, error) {
- input.CountryCode = normalizeCountryCode(input.CountryCode)
- input.Mobile = normalizeMobile(input.Mobile)
- input.Code = strings.TrimSpace(input.Code)
- if err := validateClientType(input.ClientType); err != nil {
- return nil, err
- }
- if input.Mobile == "" || input.DeviceKey == "" || input.Code == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
- }
- codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "login")
- if err != nil {
- return nil, err
- }
- if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
- if err != nil {
- return nil, err
- }
- if !consumed {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
- }
- user, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
- if err != nil {
- return nil, err
- }
- newUser := false
- if user == nil {
- userPublicID, err := security.GeneratePublicID("usr")
- if err != nil {
- return nil, err
- }
- user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
- PublicID: userPublicID,
- Status: "active",
- })
- if err != nil {
- return nil, err
- }
- if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
- UserID: user.ID,
- CountryCode: input.CountryCode,
- Mobile: input.Mobile,
- Provider: "mobile",
- ProviderSubj: input.CountryCode + ":" + input.Mobile,
- IdentityType: "mobile",
- }); err != nil {
- return nil, err
- }
- newUser = true
- }
- if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
- return nil, err
- }
- result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (s *AuthService) Refresh(ctx context.Context, input RefreshTokenInput) (*AuthResult, error) {
- input.RefreshToken = strings.TrimSpace(input.RefreshToken)
- if err := validateClientType(input.ClientType); err != nil {
- return nil, err
- }
- if input.RefreshToken == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required")
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- record, err := s.store.GetRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken))
- if err != nil {
- return nil, err
- }
- if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired")
- }
- if input.ClientType != "" && input.ClientType != record.ClientType {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token client mismatch")
- }
- if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch")
- }
- user, err := s.store.GetUserByID(ctx, tx, record.UserID)
- if err != nil {
- return nil, err
- }
- if user == nil {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
- }
- result, refreshTokenID, err := s.issueAuthResultWithRefreshID(ctx, tx, *user, record.ClientType, nullableStringValue(record.DeviceKey), false)
- if err != nil {
- return nil, err
- }
- if err := s.store.RotateRefreshToken(ctx, tx, record.ID, refreshTokenID); err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (s *AuthService) LoginWechatMini(ctx context.Context, input LoginWechatMiniInput) (*AuthResult, error) {
- input.Code = strings.TrimSpace(input.Code)
- if err := validateClientType(input.ClientType); err != nil {
- return nil, err
- }
- if input.ClientType != "wechat" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_client_type", "wechat mini login requires clientType=wechat")
- }
- if input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and deviceKey are required")
- }
- if s.cfg.WechatMini == nil {
- return nil, apperr.New(http.StatusNotImplemented, "wechat_not_configured", "wechat mini provider is not configured")
- }
- session, err := s.cfg.WechatMini.ExchangeCode(ctx, input.Code)
- if err != nil {
- return nil, apperr.New(http.StatusUnauthorized, "wechat_login_failed", err.Error())
- }
- openIDSubject := session.AppID + ":" + session.OpenID
- unionIDSubject := strings.TrimSpace(session.UnionID)
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- user, err := s.store.FindUserByProviderSubject(ctx, tx, "wechat_mini", openIDSubject)
- if err != nil {
- return nil, err
- }
- if user == nil && unionIDSubject != "" {
- user, err = s.store.FindUserByProviderSubject(ctx, tx, "wechat_unionid", unionIDSubject)
- if err != nil {
- return nil, err
- }
- }
- newUser := false
- if user == nil {
- userPublicID, err := security.GeneratePublicID("usr")
- if err != nil {
- return nil, err
- }
- user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
- PublicID: userPublicID,
- Status: "active",
- })
- if err != nil {
- return nil, err
- }
- newUser = true
- }
- profileJSON, err := json.Marshal(map[string]any{
- "appId": session.AppID,
- })
- if err != nil {
- return nil, err
- }
- if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
- UserID: user.ID,
- IdentityType: "wechat_mini_openid",
- Provider: "wechat_mini",
- ProviderSubj: openIDSubject,
- ProfileJSON: string(profileJSON),
- }); err != nil {
- return nil, err
- }
- if unionIDSubject != "" {
- if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
- UserID: user.ID,
- IdentityType: "wechat_unionid",
- Provider: "wechat_unionid",
- ProviderSubj: unionIDSubject,
- ProfileJSON: "{}",
- }); err != nil {
- return nil, err
- }
- }
- if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
- return nil, err
- }
- result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (s *AuthService) BindMobile(ctx context.Context, input BindMobileInput) (*AuthResult, error) {
- input.CountryCode = normalizeCountryCode(input.CountryCode)
- input.Mobile = normalizeMobile(input.Mobile)
- input.Code = strings.TrimSpace(input.Code)
- if err := validateClientType(input.ClientType); err != nil {
- return nil, err
- }
- if input.UserID == "" || input.Mobile == "" || input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "user, mobile, code and deviceKey are required")
- }
- codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "bind_mobile")
- if err != nil {
- return nil, err
- }
- if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
- if err != nil {
- return nil, err
- }
- if !consumed {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
- }
- currentUser, err := s.store.GetUserByID(ctx, tx, input.UserID)
- if err != nil {
- return nil, err
- }
- if currentUser == nil {
- return nil, apperr.New(http.StatusNotFound, "user_not_found", "current user not found")
- }
- mobileUser, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
- if err != nil {
- return nil, err
- }
- finalUser := currentUser
- newlyBound := false
- if mobileUser == nil {
- if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
- UserID: currentUser.ID,
- CountryCode: input.CountryCode,
- Mobile: input.Mobile,
- Provider: "mobile",
- ProviderSubj: input.CountryCode + ":" + input.Mobile,
- IdentityType: "mobile",
- }); err != nil {
- return nil, err
- }
- newlyBound = true
- } else if mobileUser.ID != currentUser.ID {
- if err := s.store.TransferNonMobileIdentities(ctx, tx, currentUser.ID, mobileUser.ID); err != nil {
- return nil, err
- }
- if err := s.store.RevokeRefreshTokensByUserID(ctx, tx, currentUser.ID); err != nil {
- return nil, err
- }
- if err := s.store.DeactivateUser(ctx, tx, currentUser.ID); err != nil {
- return nil, err
- }
- finalUser = mobileUser
- }
- if err := s.store.TouchUserLogin(ctx, tx, finalUser.ID); err != nil {
- return nil, err
- }
- result, err := s.issueAuthResult(ctx, tx, *finalUser, input.ClientType, input.DeviceKey, newlyBound)
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (s *AuthService) Logout(ctx context.Context, input LogoutInput) error {
- if strings.TrimSpace(input.RefreshToken) == "" {
- return nil
- }
- return s.store.RevokeRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
- }
- func (s *AuthService) issueAuthResult(
- ctx context.Context,
- tx postgres.Tx,
- user postgres.User,
- clientType string,
- deviceKey string,
- newUser bool,
- ) (*AuthResult, error) {
- result, _, err := s.issueAuthResultWithRefreshID(ctx, tx, user, clientType, deviceKey, newUser)
- return result, err
- }
- func (s *AuthService) issueAuthResultWithRefreshID(
- ctx context.Context,
- tx postgres.Tx,
- user postgres.User,
- clientType string,
- deviceKey string,
- newUser bool,
- ) (*AuthResult, string, error) {
- accessToken, accessExpiresAt, err := s.jwtManager.IssueAccessToken(user.ID, user.PublicID)
- if err != nil {
- return nil, "", err
- }
- refreshToken, err := security.GenerateToken(32)
- if err != nil {
- return nil, "", err
- }
- refreshTokenHash := security.HashText(refreshToken)
- refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL)
- refreshID, err := s.store.CreateRefreshToken(ctx, tx, postgres.CreateRefreshTokenParams{
- UserID: user.ID,
- ClientType: clientType,
- DeviceKey: deviceKey,
- TokenHash: refreshTokenHash,
- ExpiresAt: refreshExpiresAt,
- })
- if err != nil {
- return nil, "", err
- }
- return &AuthResult{
- User: AuthUser{
- ID: user.ID,
- PublicID: user.PublicID,
- Status: user.Status,
- Nickname: user.Nickname,
- AvatarURL: user.AvatarURL,
- },
- Tokens: AuthTokens{
- AccessToken: accessToken,
- AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
- RefreshToken: refreshToken,
- RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
- },
- NewUser: newUser,
- }, refreshID, nil
- }
- func validateClientType(clientType string) error {
- switch clientType {
- case "app", "wechat":
- return nil
- default:
- return apperr.New(http.StatusBadRequest, "invalid_client_type", "clientType must be app or wechat")
- }
- }
- func normalizeCountryCode(value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return "86"
- }
- return strings.TrimPrefix(value, "+")
- }
- func normalizeMobile(value string) string {
- value = strings.TrimSpace(value)
- value = strings.ReplaceAll(value, " ", "")
- value = strings.ReplaceAll(value, "-", "")
- return value
- }
- func normalizeScene(value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return "login"
- }
- return value
- }
- func nullableStringValue(value *string) string {
- if value == nil {
- return ""
- }
- return *value
- }
|