| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- package service
- import (
- "context"
- "net/http"
- "strings"
- "time"
- "cmr-backend/internal/apperr"
- "cmr-backend/internal/platform/jwtx"
- "cmr-backend/internal/platform/security"
- "cmr-backend/internal/store/postgres"
- )
- type OpsAuthSettings struct {
- AppEnv string
- RefreshTTL time.Duration
- SMSCodeTTL time.Duration
- SMSCodeCooldown time.Duration
- SMSProvider string
- DevSMSCode string
- }
- type OpsAuthService struct {
- cfg OpsAuthSettings
- store *postgres.Store
- jwtManager *jwtx.Manager
- }
- type OpsSendSMSCodeInput struct {
- CountryCode string `json:"countryCode"`
- Mobile string `json:"mobile"`
- DeviceKey string `json:"deviceKey"`
- Scene string `json:"scene"`
- }
- type OpsRegisterInput struct {
- CountryCode string `json:"countryCode"`
- Mobile string `json:"mobile"`
- Code string `json:"code"`
- DeviceKey string `json:"deviceKey"`
- DisplayName string `json:"displayName"`
- }
- type OpsLoginSMSInput struct {
- CountryCode string `json:"countryCode"`
- Mobile string `json:"mobile"`
- Code string `json:"code"`
- DeviceKey string `json:"deviceKey"`
- }
- type OpsRefreshTokenInput struct {
- RefreshToken string `json:"refreshToken"`
- DeviceKey string `json:"deviceKey"`
- }
- type OpsLogoutInput struct {
- RefreshToken string `json:"refreshToken"`
- }
- type OpsAuthUser struct {
- ID string `json:"id"`
- PublicID string `json:"publicId"`
- DisplayName string `json:"displayName"`
- Status string `json:"status"`
- RoleCode string `json:"roleCode"`
- }
- type OpsAuthResult struct {
- User OpsAuthUser `json:"user"`
- Tokens AuthTokens `json:"tokens"`
- NewUser bool `json:"newUser"`
- DevLoginBypass bool `json:"devLoginBypass,omitempty"`
- }
- func NewOpsAuthService(cfg OpsAuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *OpsAuthService {
- return &OpsAuthService{cfg: cfg, store: store, jwtManager: jwtManager}
- }
- func (s *OpsAuthService) SendSMSCode(ctx context.Context, input OpsSendSMSCodeInput) (*SendSMSCodeResult, error) {
- input.CountryCode = normalizeCountryCode(input.CountryCode)
- input.Mobile = normalizeMobile(input.Mobile)
- input.Scene = normalizeOpsScene(input.Scene)
- if input.Mobile == "" || strings.TrimSpace(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, "ops", 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: "ops",
- DeviceKey: input.DeviceKey,
- CodeHash: security.HashText(code),
- ProviderName: s.cfg.SMSProvider,
- ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider, "channel": "ops_console"},
- 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 *OpsAuthService) Register(ctx context.Context, input OpsRegisterInput) (*OpsAuthResult, error) {
- input.CountryCode = normalizeCountryCode(input.CountryCode)
- input.Mobile = normalizeMobile(input.Mobile)
- input.Code = strings.TrimSpace(input.Code)
- input.DeviceKey = strings.TrimSpace(input.DeviceKey)
- input.DisplayName = strings.TrimSpace(input.DisplayName)
- if input.Mobile == "" || input.Code == "" || input.DeviceKey == "" || input.DisplayName == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code, deviceKey and displayName are required")
- }
- codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, "ops", "ops_register")
- 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")
- }
- existing, err := s.store.GetOpsUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
- if err != nil {
- return nil, err
- }
- if existing != nil {
- return nil, apperr.New(http.StatusConflict, "ops_user_exists", "ops user already exists")
- }
- publicID, err := security.GeneratePublicID("ops")
- if err != nil {
- return nil, err
- }
- user, err := s.store.CreateOpsUser(ctx, tx, postgres.CreateOpsUserParams{
- PublicID: publicID,
- CountryCode: input.CountryCode,
- Mobile: input.Mobile,
- DisplayName: input.DisplayName,
- Status: "active",
- })
- if err != nil {
- return nil, err
- }
- roleCode := "operator"
- count, err := s.store.CountOpsUsers(ctx)
- if err == nil && count == 0 {
- roleCode = "owner"
- }
- role, err := s.store.GetOpsRoleByCode(ctx, tx, roleCode)
- if err != nil {
- return nil, err
- }
- if role == nil {
- return nil, apperr.New(http.StatusInternalServerError, "ops_role_missing", "default ops role is missing")
- }
- if err := s.store.AssignOpsRole(ctx, tx, user.ID, role.ID); err != nil {
- return nil, err
- }
- if err := s.store.TouchOpsUserLogin(ctx, tx, user.ID); err != nil {
- return nil, err
- }
- result, _, err := s.issueAuthResult(ctx, tx, *user, input.DeviceKey, true)
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (s *OpsAuthService) LoginSMS(ctx context.Context, input OpsLoginSMSInput) (*OpsAuthResult, error) {
- input.CountryCode = normalizeCountryCode(input.CountryCode)
- input.Mobile = normalizeMobile(input.Mobile)
- input.Code = strings.TrimSpace(input.Code)
- input.DeviceKey = strings.TrimSpace(input.DeviceKey)
- if input.Mobile == "" || input.Code == "" || input.DeviceKey == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
- }
- codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, "ops", "ops_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.GetOpsUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
- if err != nil {
- return nil, err
- }
- if user == nil {
- return nil, apperr.New(http.StatusNotFound, "ops_user_not_found", "ops user not found")
- }
- if user.Status != "active" {
- return nil, apperr.New(http.StatusForbidden, "ops_user_inactive", "ops user is not active")
- }
- if err := s.store.TouchOpsUserLogin(ctx, tx, user.ID); err != nil {
- return nil, err
- }
- result, _, err := s.issueAuthResult(ctx, tx, *user, input.DeviceKey, false)
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (s *OpsAuthService) Refresh(ctx context.Context, input OpsRefreshTokenInput) (*OpsAuthResult, error) {
- input.RefreshToken = strings.TrimSpace(input.RefreshToken)
- 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.GetOpsRefreshTokenForUpdate(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.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.GetOpsUserByID(ctx, tx, record.OpsUserID)
- if err != nil {
- return nil, err
- }
- if user == nil || user.Status != "active" {
- return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
- }
- result, newTokenID, err := s.issueAuthResult(ctx, tx, *user, nullableStringValue(record.DeviceKey), false)
- if err != nil {
- return nil, err
- }
- if err := s.store.RotateOpsRefreshToken(ctx, tx, record.ID, newTokenID); err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (s *OpsAuthService) Logout(ctx context.Context, input OpsLogoutInput) error {
- if strings.TrimSpace(input.RefreshToken) == "" {
- return nil
- }
- return s.store.RevokeOpsRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
- }
- func (s *OpsAuthService) GetMe(ctx context.Context, opsUserID string) (*OpsAuthUser, error) {
- user, err := s.store.GetOpsUserByID(ctx, s.store.Pool(), opsUserID)
- if err != nil {
- return nil, err
- }
- if user == nil {
- return nil, apperr.New(http.StatusNotFound, "ops_user_not_found", "ops user not found")
- }
- role, err := s.store.GetPrimaryOpsRole(ctx, s.store.Pool(), user.ID)
- if err != nil {
- return nil, err
- }
- result := buildOpsAuthUser(*user, role)
- return &result, nil
- }
- func (s *OpsAuthService) issueAuthResult(ctx context.Context, tx postgres.Tx, user postgres.OpsUser, deviceKey string, newUser bool) (*OpsAuthResult, string, error) {
- role, err := s.store.GetPrimaryOpsRole(ctx, tx, user.ID)
- if err != nil {
- return nil, "", err
- }
- roleCode := ""
- if role != nil {
- roleCode = role.RoleCode
- }
- accessToken, accessExpiresAt, err := s.jwtManager.IssueActorAccessToken(user.ID, user.PublicID, "ops", roleCode)
- 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.CreateOpsRefreshToken(ctx, tx, postgres.CreateOpsRefreshTokenParams{
- OpsUserID: user.ID,
- DeviceKey: deviceKey,
- TokenHash: refreshTokenHash,
- ExpiresAt: refreshExpiresAt,
- })
- if err != nil {
- return nil, "", err
- }
- result := &OpsAuthResult{
- User: buildOpsAuthUser(user, role),
- Tokens: AuthTokens{
- AccessToken: accessToken,
- AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
- RefreshToken: refreshToken,
- RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
- },
- NewUser: newUser,
- }
- return result, refreshID, nil
- }
- func buildOpsAuthUser(user postgres.OpsUser, role *postgres.OpsRole) OpsAuthUser {
- roleCode := ""
- if role != nil {
- roleCode = role.RoleCode
- }
- return OpsAuthUser{
- ID: user.ID,
- PublicID: user.PublicID,
- DisplayName: user.DisplayName,
- Status: user.Status,
- RoleCode: roleCode,
- }
- }
- func normalizeOpsScene(value string) string {
- switch strings.TrimSpace(value) {
- case "ops_register":
- return "ops_register"
- default:
- return "ops_login"
- }
- }
|