File size: 8,296 Bytes
f1dd159 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | package adminauth
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/chenyme/grok2api/backend/internal/domain/admin"
"github.com/chenyme/grok2api/backend/internal/infra/security"
"github.com/chenyme/grok2api/backend/internal/repository"
)
var (
ErrInvalidCredentials = errors.New("管理员账号或密码错误")
ErrInvalidSession = errors.New("管理员会话无效")
ErrBootstrapRequired = errors.New("首次启动需要设置管理员账号和密码")
ErrInvalidPassword = errors.New("新密码至少需要 8 个字符")
ErrLoginRateLimited = errors.New("管理员登录尝试过于频繁")
ErrRuntimeUnavailable = errors.New("管理员认证运行态暂不可用")
)
type Tokens struct {
AccessToken string
AccessTokenExpiresAt time.Time
RefreshToken string
RefreshTokenExpiresAt time.Time
}
// Service 负责编排单管理员登录、JWT 和 refresh session 生命周期。
type Service struct {
admins repository.AdminRepository
sessions repository.AdminSessionRepository
tokens *security.TokenService
accessTTL time.Duration
refreshTTL time.Duration
loginLimiter repository.RateLimiter
dummyPasswordHash string
}
func NewService(admins repository.AdminRepository, sessions repository.AdminSessionRepository, tokens *security.TokenService, accessTTL, refreshTTL time.Duration) *Service {
dummyHash, _ := security.HashPassword("grok2api-invalid-admin-password")
return &Service{admins: admins, sessions: sessions, tokens: tokens, accessTTL: accessTTL, refreshTTL: refreshTTL, dummyPasswordHash: dummyHash}
}
func (s *Service) SetLoginRateLimiter(limiter repository.RateLimiter) { s.loginLimiter = limiter }
// Bootstrap 在数据库没有管理员时创建唯一管理员。
func (s *Service) Bootstrap(ctx context.Context, username, password string) error {
count, err := s.admins.Count(ctx)
if err != nil {
return err
}
if count > 0 {
return nil
}
if strings.TrimSpace(username) == "" || len(password) < 8 {
return ErrBootstrapRequired
}
hash, err := security.HashPassword(password)
if err != nil {
return err
}
_, err = s.admins.Create(ctx, admin.Admin{Username: strings.TrimSpace(username), PasswordHash: hash})
return err
}
// Login 校验密码并创建新的可撤销 refresh session。
func (s *Service) Login(ctx context.Context, username, password, remoteAddress string) (admin.Admin, Tokens, error) {
username = strings.TrimSpace(username)
if err := s.checkLoginRate(ctx, username, remoteAddress); err != nil {
return admin.Admin{}, Tokens{}, err
}
value, err := s.admins.GetByUsername(ctx, username)
if err != nil {
_ = security.VerifyPassword(s.dummyPasswordHash, password)
if !errors.Is(err, repository.ErrNotFound) {
return admin.Admin{}, Tokens{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
return admin.Admin{}, Tokens{}, ErrInvalidCredentials
}
if !security.VerifyPassword(value.PasswordHash, password) {
return admin.Admin{}, Tokens{}, ErrInvalidCredentials
}
tokens, _, err := s.createSession(ctx, value.ID)
return value, tokens, err
}
// Refresh 轮换 refresh token,旧 token 立即失效。
func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (Tokens, error) {
hash := security.HashToken(rawRefreshToken)
session, err := s.sessions.GetByTokenHash(ctx, hash)
if err != nil {
if !errors.Is(err, repository.ErrNotFound) {
return Tokens{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
return Tokens{}, ErrInvalidSession
}
if !time.Now().UTC().Before(session.ExpiresAt) {
return Tokens{}, ErrInvalidSession
}
adminValue, err := s.admins.GetByID(ctx, session.AdminID)
if err != nil {
if !errors.Is(err, repository.ErrNotFound) {
return Tokens{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
return Tokens{}, ErrInvalidSession
}
accessToken, accessExpiresAt, err := s.tokens.CreateAccessToken(adminValue.ID, session.ID, s.accessTTL)
if err != nil {
return Tokens{}, err
}
refreshToken, err := security.NewOpaqueToken(32)
if err != nil {
return Tokens{}, err
}
refreshExpiresAt := time.Now().UTC().Add(s.refreshTTL)
if err := s.sessions.Rotate(ctx, session.ID, hash, security.HashToken(refreshToken), refreshExpiresAt); err != nil {
if errors.Is(err, repository.ErrConflict) || errors.Is(err, repository.ErrNotFound) {
return Tokens{}, ErrInvalidSession
}
return Tokens{}, err
}
return Tokens{AccessToken: accessToken, AccessTokenExpiresAt: accessExpiresAt, RefreshToken: refreshToken, RefreshTokenExpiresAt: refreshExpiresAt}, nil
}
// Logout 撤销当前 refresh session。
func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
session, err := s.sessions.GetByTokenHash(ctx, security.HashToken(rawRefreshToken))
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil
}
return fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
if err := s.sessions.Revoke(ctx, session.ID); err != nil && !errors.Is(err, repository.ErrNotFound) {
return fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
return nil
}
// AuthenticateAccess 校验 access token 并读取管理员。
func (s *Service) AuthenticateAccess(ctx context.Context, rawAccessToken string) (admin.Admin, error) {
identity, err := s.tokens.ParseAccessToken(rawAccessToken)
if err != nil {
return admin.Admin{}, ErrInvalidSession
}
session, err := s.sessions.GetByID(ctx, identity.SessionID)
if err != nil {
if !errors.Is(err, repository.ErrNotFound) {
return admin.Admin{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
return admin.Admin{}, ErrInvalidSession
}
if session.AdminID != identity.AdminID || !time.Now().UTC().Before(session.ExpiresAt) {
return admin.Admin{}, ErrInvalidSession
}
value, err := s.admins.GetByID(ctx, identity.AdminID)
if err != nil {
if !errors.Is(err, repository.ErrNotFound) {
return admin.Admin{}, fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
return admin.Admin{}, ErrInvalidSession
}
return value, nil
}
// ChangePassword 修改密码并撤销管理员的全部 refresh session。
func (s *Service) ChangePassword(ctx context.Context, adminID uint64, currentPassword, newPassword string) error {
if len(newPassword) < 8 {
return ErrInvalidPassword
}
value, err := s.admins.GetByID(ctx, adminID)
if err != nil {
if !errors.Is(err, repository.ErrNotFound) {
return fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
return ErrInvalidCredentials
}
if !security.VerifyPassword(value.PasswordHash, currentPassword) {
return ErrInvalidCredentials
}
hash, err := security.HashPassword(newPassword)
if err != nil {
return err
}
return s.admins.UpdatePasswordAndRevokeSessions(ctx, adminID, hash)
}
func (s *Service) createSession(ctx context.Context, adminID uint64) (Tokens, admin.Session, error) {
refreshToken, err := security.NewOpaqueToken(32)
if err != nil {
return Tokens{}, admin.Session{}, err
}
refreshExpiresAt := time.Now().UTC().Add(s.refreshTTL)
session, err := s.sessions.Create(ctx, admin.Session{AdminID: adminID, RefreshTokenHash: security.HashToken(refreshToken), ExpiresAt: refreshExpiresAt})
if err != nil {
return Tokens{}, admin.Session{}, err
}
accessToken, accessExpiresAt, err := s.tokens.CreateAccessToken(adminID, session.ID, s.accessTTL)
if err != nil {
_ = s.sessions.Revoke(ctx, session.ID)
return Tokens{}, admin.Session{}, err
}
return Tokens{AccessToken: accessToken, AccessTokenExpiresAt: accessExpiresAt, RefreshToken: refreshToken, RefreshTokenExpiresAt: refreshExpiresAt}, session, err
}
func (s *Service) checkLoginRate(ctx context.Context, username, remoteAddress string) error {
if s.loginLimiter == nil {
return nil
}
now := time.Now().UTC()
keys := []struct {
key string
limit int
}{
{key: "admin-login:ip:" + security.HashToken(strings.TrimSpace(remoteAddress)), limit: 30},
{key: "admin-login:user:" + security.HashToken(strings.ToLower(username)), limit: 12},
}
for _, item := range keys {
allowed, err := s.loginLimiter.Allow(ctx, item.key, item.limit, now)
if err != nil {
return fmt.Errorf("%w: %v", ErrRuntimeUnavailable, err)
}
if !allowed {
return ErrLoginRateLimited
}
}
return nil
}
|