662 lines
23 KiB
Go
662 lines
23 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"crypto/subtle"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
jwt "github.com/golang-jwt/jwt/v5"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"hyapp/pkg/idgen"
|
||
"hyapp/pkg/xerr"
|
||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
userservice "hyapp/services/user-service/internal/service/user"
|
||
)
|
||
|
||
const (
|
||
hashAlgBcrypt = "bcrypt"
|
||
tokenType = "Bearer"
|
||
loginPassword = "password"
|
||
loginThird = "third_party"
|
||
loginRefresh = "refresh"
|
||
loginSetPass = "set_password"
|
||
resultSuccess = "success"
|
||
resultFailed = "failed"
|
||
)
|
||
|
||
// AuthRepository 是认证用例的持久化边界。
|
||
// 跨表写入仍按用例保留事务入口,例如三方首次登录要一次性创建用户、默认短号、三方身份和 session。
|
||
type AuthRepository interface {
|
||
SetPassword(ctx context.Context, account authdomain.PasswordAccount) error
|
||
FindPasswordByDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (authdomain.PasswordAccount, error)
|
||
CreateSession(ctx context.Context, session authdomain.Session) error
|
||
FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error)
|
||
ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error
|
||
RevokeSession(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64) (bool, error)
|
||
FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error)
|
||
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error
|
||
RecordLoginAudit(ctx context.Context, audit authdomain.LoginAudit) error
|
||
}
|
||
|
||
// UserRepository 为 auth service 提供登录签发前的用户快照读取。
|
||
// Auth service 不关心用户创建和短号修改细节,只需要判断用户是否可登录以及签发当前展示号。
|
||
type UserRepository interface {
|
||
GetUser(ctx context.Context, userID int64) (userdomain.User, error)
|
||
}
|
||
|
||
// IdentityRepository 为 auth service 提供靓号懒过期能力。
|
||
// 过期恢复仍由 identity repository 的事务完成,避免 auth service 手动改 users 和短号表。
|
||
type IdentityRepository interface {
|
||
ExpirePrettyDisplayUserID(ctx context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error)
|
||
}
|
||
|
||
// Repository 是兼容旧 WithRepository 调用的组合接口。
|
||
// 新增实现可以分别注入 AuthRepository、UserRepository 和 IdentityRepository,避免继续扩大单一接口。
|
||
type Repository interface {
|
||
AuthRepository
|
||
UserRepository
|
||
IdentityRepository
|
||
}
|
||
|
||
// ThirdPartyVerifier 把 provider credential 校验成稳定 provider_subject。
|
||
// gateway 不接触 provider secret,三方校验必须停留在 user-service。
|
||
type ThirdPartyVerifier interface {
|
||
Verify(ctx context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error)
|
||
}
|
||
|
||
// Config 保存认证底座需要的稳定参数。
|
||
type Config struct {
|
||
Issuer string
|
||
AccessTokenTTLSec int64
|
||
RefreshTokenTTLSec int64
|
||
SigningAlg string
|
||
SigningSecret string
|
||
}
|
||
|
||
// Service 承载登录注册用例。
|
||
type Service struct {
|
||
cfg Config
|
||
authRepository AuthRepository
|
||
userRepository UserRepository
|
||
identityRepository IdentityRepository
|
||
idGenerator userservice.IDGenerator
|
||
displayUserIDAllocator userservice.DisplayUserIDAllocator
|
||
thirdPartyVerifier ThirdPartyVerifier
|
||
now func() time.Time
|
||
allocateMaxAttempts int
|
||
}
|
||
|
||
// Option 调整 auth service 的依赖和策略。
|
||
type Option func(*Service)
|
||
|
||
// New 创建认证服务。
|
||
func New(cfg Config, options ...Option) *Service {
|
||
svc := &Service{
|
||
cfg: cfg,
|
||
idGenerator: idgen.NewInt64Generator(0),
|
||
displayUserIDAllocator: userservice.NumericDisplayUserIDAllocator{},
|
||
thirdPartyVerifier: NewStaticThirdPartyVerifier([]string{"wechat"}),
|
||
now: time.Now,
|
||
allocateMaxAttempts: 8,
|
||
}
|
||
|
||
for _, option := range options {
|
||
option(svc)
|
||
}
|
||
|
||
return svc
|
||
}
|
||
|
||
// WithRepository 注入认证持久化实现。
|
||
func WithRepository(repository Repository) Option {
|
||
return func(s *Service) {
|
||
s.authRepository = repository
|
||
s.userRepository = repository
|
||
s.identityRepository = repository
|
||
}
|
||
}
|
||
|
||
// WithAuthRepository 注入纯认证持久化实现。
|
||
func WithAuthRepository(repository AuthRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.authRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithUserRepository 注入用户快照读取实现。
|
||
func WithUserRepository(repository UserRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.userRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithIdentityRepository 注入短号懒过期实现。
|
||
func WithIdentityRepository(repository IdentityRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.identityRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithIDGenerator 注入 user_id 发号器。
|
||
func WithIDGenerator(generator userservice.IDGenerator) Option {
|
||
return func(s *Service) {
|
||
if generator != nil {
|
||
s.idGenerator = generator
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithDisplayUserIDAllocator 注入短号候选生成器。
|
||
func WithDisplayUserIDAllocator(allocator userservice.DisplayUserIDAllocator) Option {
|
||
return func(s *Service) {
|
||
if allocator != nil {
|
||
s.displayUserIDAllocator = allocator
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithThirdPartyVerifier 注入三方 provider 校验器。
|
||
func WithThirdPartyVerifier(verifier ThirdPartyVerifier) Option {
|
||
return func(s *Service) {
|
||
if verifier != nil {
|
||
s.thirdPartyVerifier = verifier
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithClock 注入时间函数,保证 session 轮换测试稳定。
|
||
func WithClock(now func() time.Time) Option {
|
||
return func(s *Service) {
|
||
if now != nil {
|
||
s.now = now
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithDisplayUserIDAllocateMaxAttempts 配置默认短号分配重试次数。
|
||
func WithDisplayUserIDAllocateMaxAttempts(maxAttempts int) Option {
|
||
return func(s *Service) {
|
||
if maxAttempts > 0 {
|
||
s.allocateMaxAttempts = maxAttempts
|
||
}
|
||
}
|
||
}
|
||
|
||
// LoginPassword 使用当前 display_user_id 和用户后续设置的密码创建新 session。
|
||
// 用户不存在、未设置密码和密码错误统一返回 AUTH_FAILED,避免暴露可枚举身份状态。
|
||
func (s *Service) LoginPassword(ctx context.Context, displayUserID string, password string, deviceID string, meta Meta) (authdomain.Token, error) {
|
||
displayUserID = strings.TrimSpace(displayUserID)
|
||
if displayUserID == "" || password == "" || strings.TrimSpace(deviceID) == "" {
|
||
return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "display_user_id, password and device_id are required")
|
||
}
|
||
if s.authRepository == nil {
|
||
return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||
}
|
||
if !userdomain.ValidDisplayUserID(displayUserID) {
|
||
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.AuthFailed))
|
||
return authdomain.Token{}, authFailed()
|
||
}
|
||
|
||
nowMs := s.now().UnixMilli()
|
||
accountRecord, err := s.authRepository.FindPasswordByDisplayUserID(ctx, displayUserID, nowMs)
|
||
if err != nil {
|
||
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.AuthFailed))
|
||
return authdomain.Token{}, authFailed()
|
||
}
|
||
if err := verifyPassword(accountRecord.PasswordHash, password); err != nil {
|
||
s.audit(ctx, meta, accountRecord.UserID, loginPassword, "", resultFailed, string(xerr.AuthFailed))
|
||
return authdomain.Token{}, authFailed()
|
||
}
|
||
|
||
user, err := s.freshUser(ctx, accountRecord.UserID, nowMs, meta.RequestID)
|
||
if err != nil {
|
||
s.audit(ctx, meta, accountRecord.UserID, loginPassword, "", resultFailed, string(xerr.CodeOf(err)))
|
||
return authdomain.Token{}, err
|
||
}
|
||
if !user.CanLogin() {
|
||
s.audit(ctx, meta, user.UserID, loginPassword, "", resultFailed, string(xerr.UserDisabled))
|
||
return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled")
|
||
}
|
||
|
||
session, refreshToken, err := s.newSession(user.UserID, deviceID)
|
||
if err != nil {
|
||
return authdomain.Token{}, err
|
||
}
|
||
if err := s.authRepository.CreateSession(ctx, session); err != nil {
|
||
s.audit(ctx, meta, user.UserID, loginPassword, "", resultFailed, string(xerr.CodeOf(err)))
|
||
return authdomain.Token{}, err
|
||
}
|
||
|
||
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
|
||
return s.issueToken(user, session.SessionID, refreshToken)
|
||
}
|
||
|
||
// LoginThirdParty 校验三方凭证,存在则登录,不存在则创建用户和三方绑定。
|
||
func (s *Service) LoginThirdParty(ctx context.Context, provider string, credential string, deviceID string, meta Meta) (authdomain.Token, bool, error) {
|
||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||
if provider == "" || strings.TrimSpace(credential) == "" || strings.TrimSpace(deviceID) == "" {
|
||
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "provider, credential and device_id are required")
|
||
}
|
||
if s.authRepository == nil {
|
||
return authdomain.Token{}, false, xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||
}
|
||
|
||
profile, err := s.thirdPartyVerifier.Verify(ctx, provider, credential)
|
||
if err != nil {
|
||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.AuthFailed))
|
||
return authdomain.Token{}, false, authFailed()
|
||
}
|
||
|
||
identity, err := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
|
||
if err == nil {
|
||
return s.loginExistingThirdParty(ctx, identity, deviceID, meta)
|
||
}
|
||
if !xerr.IsCode(err, xerr.NotFound) {
|
||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
|
||
return authdomain.Token{}, false, err
|
||
}
|
||
|
||
return s.createThirdPartyUser(ctx, provider, profile, deviceID, meta)
|
||
}
|
||
|
||
// SetPassword 为已经三方登录创建出的用户首次设置密码。
|
||
// 该入口必须由 gateway 先校验 access token 后传入 user_id,不能再接受客户端自报用户。
|
||
func (s *Service) SetPassword(ctx context.Context, userID int64, password string, meta Meta) error {
|
||
if userID <= 0 || password == "" {
|
||
return xerr.New(xerr.InvalidArgument, "user_id and password are required")
|
||
}
|
||
if s.authRepository == nil {
|
||
return xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||
}
|
||
|
||
user, err := s.freshUser(ctx, userID, s.now().UnixMilli(), meta.RequestID)
|
||
if err != nil {
|
||
s.audit(ctx, meta, userID, loginSetPass, "", resultFailed, string(xerr.CodeOf(err)))
|
||
return err
|
||
}
|
||
if !user.CanLogin() {
|
||
s.audit(ctx, meta, userID, loginSetPass, "", resultFailed, string(xerr.UserDisabled))
|
||
return xerr.New(xerr.UserDisabled, "user is disabled")
|
||
}
|
||
|
||
passwordHash, err := hashPassword(password)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
nowMs := s.now().UnixMilli()
|
||
err = s.authRepository.SetPassword(ctx, authdomain.PasswordAccount{
|
||
UserID: user.UserID,
|
||
DisplayUserID: user.CurrentDisplayUserID,
|
||
PasswordHash: passwordHash,
|
||
HashAlg: hashAlgBcrypt,
|
||
CreatedAtMs: nowMs,
|
||
UpdatedAtMs: nowMs,
|
||
})
|
||
if err != nil {
|
||
s.audit(ctx, meta, userID, loginSetPass, "", resultFailed, string(xerr.CodeOf(err)))
|
||
return err
|
||
}
|
||
|
||
s.audit(ctx, meta, userID, loginSetPass, "", resultSuccess, "")
|
||
return nil
|
||
}
|
||
|
||
// RefreshToken 轮换 refresh token,旧 token 成功使用后立即失效。
|
||
func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceID string, meta Meta) (authdomain.Token, error) {
|
||
if strings.TrimSpace(refreshToken) == "" || strings.TrimSpace(deviceID) == "" {
|
||
return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "refresh_token and device_id are required")
|
||
}
|
||
if s.authRepository == nil {
|
||
return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||
}
|
||
|
||
nowMs := s.now().UnixMilli()
|
||
session, err := s.authRepository.FindActiveSessionByRefreshHash(ctx, hashRefreshToken(refreshToken), nowMs)
|
||
if err != nil {
|
||
reason := xerr.SessionRevoked
|
||
if xerr.IsCode(err, xerr.SessionExpired) {
|
||
reason = xerr.SessionExpired
|
||
}
|
||
s.audit(ctx, meta, 0, loginRefresh, "", resultFailed, string(reason))
|
||
return authdomain.Token{}, err
|
||
}
|
||
if subtle.ConstantTimeCompare([]byte(session.DeviceID), []byte(deviceID)) != 1 {
|
||
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.SessionRevoked))
|
||
return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked")
|
||
}
|
||
user, err := s.freshUser(ctx, session.UserID, nowMs, meta.RequestID)
|
||
if err != nil {
|
||
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err)))
|
||
return authdomain.Token{}, err
|
||
}
|
||
if !user.CanLogin() {
|
||
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.UserDisabled))
|
||
return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled")
|
||
}
|
||
|
||
newSession, newRefreshToken, err := s.newSession(session.UserID, deviceID)
|
||
if err != nil {
|
||
return authdomain.Token{}, err
|
||
}
|
||
if err := s.authRepository.ReplaceSession(ctx, session.SessionID, newSession, nowMs); err != nil {
|
||
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err)))
|
||
return authdomain.Token{}, err
|
||
}
|
||
|
||
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultSuccess, "")
|
||
return s.issueToken(user, newSession.SessionID, newRefreshToken)
|
||
}
|
||
|
||
// Logout 失效指定 session 或 refresh token。
|
||
func (s *Service) Logout(ctx context.Context, sessionID string, refreshToken string, meta Meta) (bool, error) {
|
||
if strings.TrimSpace(sessionID) == "" && strings.TrimSpace(refreshToken) == "" {
|
||
return false, xerr.New(xerr.InvalidArgument, "session_id or refresh_token is required")
|
||
}
|
||
if s.authRepository == nil {
|
||
return false, xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||
}
|
||
|
||
revoked, err := s.authRepository.RevokeSession(ctx, sessionID, hashRefreshToken(refreshToken), s.now().UnixMilli())
|
||
if err != nil {
|
||
s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err)))
|
||
return false, err
|
||
}
|
||
|
||
s.audit(ctx, meta, 0, "logout", "", resultSuccess, "")
|
||
return revoked, nil
|
||
}
|
||
|
||
// TokenConfigValid 判断签发配置是否具备启动条件。
|
||
func (s *Service) TokenConfigValid() bool {
|
||
return s.cfg.Issuer != "" && s.cfg.AccessTokenTTLSec > 0 && s.cfg.RefreshTokenTTLSec > 0 && s.cfg.SigningAlg == jwt.SigningMethodHS256.Alg() && s.cfg.SigningSecret != ""
|
||
}
|
||
|
||
// Meta 是 gateway 透传到 user-service 的审计元信息。
|
||
type Meta struct {
|
||
RequestID string
|
||
ClientIP string
|
||
UserAgent string
|
||
}
|
||
|
||
func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdomain.ThirdPartyIdentity, deviceID string, meta Meta) (authdomain.Token, bool, error) {
|
||
user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
|
||
if err != nil {
|
||
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
|
||
return authdomain.Token{}, false, err
|
||
}
|
||
if !user.CanLogin() {
|
||
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultFailed, string(xerr.UserDisabled))
|
||
return authdomain.Token{}, false, xerr.New(xerr.UserDisabled, "user is disabled")
|
||
}
|
||
|
||
session, refreshToken, err := s.newSession(user.UserID, deviceID)
|
||
if err != nil {
|
||
return authdomain.Token{}, false, err
|
||
}
|
||
if err := s.authRepository.CreateSession(ctx, session); err != nil {
|
||
return authdomain.Token{}, false, err
|
||
}
|
||
|
||
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultSuccess, "")
|
||
token, err := s.issueToken(user, session.SessionID, refreshToken)
|
||
return token, false, err
|
||
}
|
||
|
||
func (s *Service) createThirdPartyUser(ctx context.Context, provider string, profile authdomain.ThirdPartyProfile, deviceID string, meta Meta) (authdomain.Token, bool, error) {
|
||
var lastErr error
|
||
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
|
||
user, displayIdentity := s.newUserWithIdentity(attempt)
|
||
if !userdomain.ValidDisplayUserID(displayIdentity.DisplayUserID) {
|
||
continue
|
||
}
|
||
session, refreshToken, err := s.newSession(user.UserID, deviceID)
|
||
if err != nil {
|
||
return authdomain.Token{}, false, err
|
||
}
|
||
|
||
thirdParty := authdomain.ThirdPartyIdentity{
|
||
UserID: user.UserID,
|
||
Provider: provider,
|
||
ProviderSubject: profile.ProviderSubject,
|
||
ProviderUnionID: profile.ProviderUnionID,
|
||
CreatedAtMs: user.CreatedAtMs,
|
||
UpdatedAtMs: user.UpdatedAtMs,
|
||
}
|
||
|
||
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session)
|
||
if err == nil {
|
||
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
|
||
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken)
|
||
return token, true, tokenErr
|
||
}
|
||
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
if xerr.IsCode(err, xerr.Conflict) {
|
||
identity, findErr := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
|
||
if findErr == nil {
|
||
return s.loginExistingThirdParty(ctx, identity, deviceID, meta)
|
||
}
|
||
}
|
||
|
||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
|
||
return authdomain.Token{}, false, err
|
||
}
|
||
|
||
if lastErr != nil {
|
||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.DisplayUserIDAllocateFailed))
|
||
}
|
||
return authdomain.Token{}, false, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
|
||
}
|
||
|
||
func (s *Service) newUserWithIdentity(attempt int) (userdomain.User, userdomain.Identity) {
|
||
userID := s.idGenerator.NewInt64()
|
||
nowMs := s.now().UnixMilli()
|
||
displayUserID := s.displayUserIDAllocator.NextDisplayUserID(userID, attempt)
|
||
|
||
return userdomain.User{
|
||
UserID: userID,
|
||
DefaultDisplayUserID: displayUserID,
|
||
CurrentDisplayUserID: displayUserID,
|
||
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||
Status: userdomain.StatusActive,
|
||
CreatedAtMs: nowMs,
|
||
UpdatedAtMs: nowMs,
|
||
}, userdomain.Identity{
|
||
UserID: userID,
|
||
DisplayUserID: displayUserID,
|
||
DefaultDisplayUserID: displayUserID,
|
||
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||
Status: userdomain.DisplayUserIDStatusActive,
|
||
}
|
||
}
|
||
|
||
func (s *Service) newSession(userID int64, deviceID string) (authdomain.Session, string, error) {
|
||
refreshToken, err := randomToken("refresh")
|
||
if err != nil {
|
||
return authdomain.Session{}, "", err
|
||
}
|
||
|
||
nowMs := s.now().UnixMilli()
|
||
sessionID := idgen.New("sess")
|
||
return authdomain.Session{
|
||
SessionID: sessionID,
|
||
UserID: userID,
|
||
RefreshTokenHash: hashRefreshToken(refreshToken),
|
||
DeviceID: strings.TrimSpace(deviceID),
|
||
ExpiresAtMs: nowMs + s.cfg.RefreshTokenTTLSec*1000,
|
||
CreatedAtMs: nowMs,
|
||
UpdatedAtMs: nowMs,
|
||
}, refreshToken, nil
|
||
}
|
||
|
||
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string) (authdomain.Token, error) {
|
||
user = user.NormalizeDisplayUserIDState()
|
||
now := s.now()
|
||
expiresAt := now.Add(time.Duration(s.cfg.AccessTokenTTLSec) * time.Second)
|
||
claims := jwt.MapClaims{
|
||
"iss": s.cfg.Issuer,
|
||
"sub": strconv.FormatInt(user.UserID, 10),
|
||
"user_id": float64(user.UserID),
|
||
"display_user_id": user.CurrentDisplayUserID,
|
||
"default_display_user_id": user.DefaultDisplayUserID,
|
||
"display_user_id_kind": string(user.CurrentDisplayUserIDKind),
|
||
"display_user_id_expires_at_ms": user.CurrentDisplayUserIDExpiresAtMs,
|
||
"sid": sessionID,
|
||
"typ": "access",
|
||
"iat": now.Unix(),
|
||
"exp": expiresAt.Unix(),
|
||
}
|
||
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||
accessToken, err := token.SignedString([]byte(s.cfg.SigningSecret))
|
||
if err != nil {
|
||
return authdomain.Token{}, err
|
||
}
|
||
|
||
return authdomain.Token{
|
||
UserID: user.UserID,
|
||
DisplayUserID: user.CurrentDisplayUserID,
|
||
DefaultDisplayUserID: user.DefaultDisplayUserID,
|
||
DisplayUserIDKind: string(user.CurrentDisplayUserIDKind),
|
||
DisplayUserIDExpiresAtMs: user.CurrentDisplayUserIDExpiresAtMs,
|
||
SessionID: sessionID,
|
||
AccessToken: accessToken,
|
||
RefreshToken: refreshToken,
|
||
ExpiresInSec: s.cfg.AccessTokenTTLSec,
|
||
TokenType: tokenType,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) freshUser(ctx context.Context, userID int64, nowMs int64, requestID string) (userdomain.User, error) {
|
||
if s.userRepository == nil {
|
||
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||
}
|
||
|
||
user, err := s.userRepository.GetUser(ctx, userID)
|
||
if err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
user = user.NormalizeDisplayUserIDState()
|
||
if !user.DisplayUserIDExpired(nowMs) {
|
||
return user, nil
|
||
}
|
||
|
||
if s.identityRepository == nil {
|
||
return userdomain.User{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
|
||
}
|
||
|
||
if _, err := s.identityRepository.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{
|
||
UserID: userID,
|
||
RequestID: requestID,
|
||
NowMs: nowMs,
|
||
}); err != nil {
|
||
return userdomain.User{}, err
|
||
}
|
||
|
||
return s.userRepository.GetUser(ctx, userID)
|
||
}
|
||
|
||
func (s *Service) audit(ctx context.Context, meta Meta, userID int64, loginType string, provider string, result string, failureCode string) {
|
||
if s.authRepository == nil {
|
||
return
|
||
}
|
||
|
||
_ = s.authRepository.RecordLoginAudit(ctx, authdomain.LoginAudit{
|
||
RequestID: meta.RequestID,
|
||
UserID: userID,
|
||
LoginType: loginType,
|
||
Provider: provider,
|
||
Result: result,
|
||
FailureCode: failureCode,
|
||
ClientIP: meta.ClientIP,
|
||
UserAgent: meta.UserAgent,
|
||
CreatedAtMs: s.now().UnixMilli(),
|
||
})
|
||
}
|
||
|
||
// StaticThirdPartyVerifier 是本地 v1 verifier。
|
||
// credential 在首版本地实现中就是 provider_subject;真实 SDK 可替换该接口。
|
||
type StaticThirdPartyVerifier struct {
|
||
allowed map[string]bool
|
||
}
|
||
|
||
// NewStaticThirdPartyVerifier 创建 provider allowlist 校验器。
|
||
func NewStaticThirdPartyVerifier(providers []string) *StaticThirdPartyVerifier {
|
||
allowed := make(map[string]bool, len(providers))
|
||
for _, provider := range providers {
|
||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||
if provider != "" {
|
||
allowed[provider] = true
|
||
}
|
||
}
|
||
|
||
return &StaticThirdPartyVerifier{allowed: allowed}
|
||
}
|
||
|
||
// Verify 把本地 credential 解析为 provider_subject。
|
||
func (v *StaticThirdPartyVerifier) Verify(_ context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error) {
|
||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||
subject := strings.TrimSpace(credential)
|
||
if !v.allowed[provider] || subject == "" {
|
||
return authdomain.ThirdPartyProfile{}, authFailed()
|
||
}
|
||
|
||
return authdomain.ThirdPartyProfile{ProviderSubject: subject}, nil
|
||
}
|
||
|
||
func hashPassword(password string) (string, error) {
|
||
body, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return string(body), nil
|
||
}
|
||
|
||
func verifyPassword(passwordHash string, password string) error {
|
||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
|
||
return authFailed()
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func randomToken(prefix string) (string, error) {
|
||
var entropy [32]byte
|
||
if _, err := rand.Read(entropy[:]); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return fmt.Sprintf("%s_%s", prefix, base64.RawURLEncoding.EncodeToString(entropy[:])), nil
|
||
}
|
||
|
||
func hashRefreshToken(refreshToken string) string {
|
||
if refreshToken == "" {
|
||
return ""
|
||
}
|
||
|
||
sum := sha256.Sum256([]byte(refreshToken))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func authFailed() error {
|
||
return xerr.New(xerr.AuthFailed, "authentication failed")
|
||
}
|