Files
cmbone/internal/services/auth_service.go
T

285 lines
7.5 KiB
Go

package services
import (
"cmbone/internal/models"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
const (
authProviderLocal = "local"
authProviderRemote = "remote"
auditActionLoginSuccess = "auth.login.success"
auditActionLoginFailure = "auth.login.failure"
auditActionLogout = "auth.logout"
)
var (
errInvalidCredentials = errors.New("用户名或密码错误")
errRemoteProviderNotReady = errors.New("remote auth provider is not configured")
errUnsupportedAuthProvider = errors.New("unsupported auth provider")
errMissingUsernameOrPassword = errors.New("用户名和密码不能为空")
)
type AuthService struct {
store *SuiStore
providers map[string]AuthProvider
}
type AuthProvider interface {
Mode() string
Authenticate(username string, password string) (authPrincipal, error)
}
type authPrincipal struct {
UserID string
Username string
Provider string
}
func NewAuthService(store *SuiStore) *AuthService {
service := &AuthService{store: store}
service.providers = map[string]AuthProvider{
authProviderLocal: &LocalAuthProvider{store: store},
authProviderRemote: &RemoteAuthProvider{},
}
return service
}
func (s *AuthService) GetAuthProviders() []models.AuthProviderOption {
return []models.AuthProviderOption{
{
Value: authProviderLocal,
Label: "Local",
Description: "本地固定账号 provider,用于模板演示和离线模式",
Enabled: true,
},
{
Value: authProviderRemote,
Label: "Remote",
Description: "真实后端 provider 预留入口",
Enabled: false,
},
}
}
func (s *AuthService) GetAuthMode() string {
mode, err := NewAppConfigService(s.store).GetAppConfig("auth.provider")
if err != nil || mode == "" {
return authProviderLocal
}
return mode
}
func (s *AuthService) SetAuthMode(mode string) error {
if _, ok := s.providers[mode]; !ok {
return fmt.Errorf("%w: %s", errUnsupportedAuthProvider, mode)
}
return NewAppConfigService(s.store).SetAppConfig("auth.provider", mode)
}
func (s *AuthService) Login(provider string, username string, password string) (models.AuthSession, error) {
provider = strings.TrimSpace(provider)
username = strings.TrimSpace(username)
if provider == "" {
provider = s.GetAuthMode()
}
if username == "" || password == "" {
_ = s.recordAudit(username, auditActionLoginFailure, "auth_session", "", "missing username or password")
return models.AuthSession{}, errMissingUsernameOrPassword
}
authProvider, ok := s.providers[provider]
if !ok {
_ = s.recordAudit(username, auditActionLoginFailure, "auth_session", provider, "unsupported provider")
return models.AuthSession{}, fmt.Errorf("%w: %s", errUnsupportedAuthProvider, provider)
}
principal, err := authProvider.Authenticate(username, password)
if err != nil {
_ = s.recordAudit(username, auditActionLoginFailure, "auth_session", provider, err.Error())
return models.AuthSession{}, err
}
session, err := s.createSession(principal)
if err != nil {
return models.AuthSession{}, err
}
_ = s.SetAuthMode(provider)
_ = s.recordAudit(principal.Username, auditActionLoginSuccess, "auth_session", fmt.Sprint(session.ID), "login succeeded")
return session, nil
}
func (s *AuthService) Logout() error {
session, err := s.GetCurrentSession()
if err != nil {
return err
}
if !session.Active {
return nil
}
_, err = s.store.DB.Exec("UPDATE auth_sessions SET active = 0 WHERE id = ?", session.ID)
if err != nil {
return err
}
return s.recordAudit(session.Username, auditActionLogout, "auth_session", fmt.Sprint(session.ID), "logout succeeded")
}
func (s *AuthService) GetCurrentSession() (models.AuthSession, error) {
now := time.Now().UTC().Format(time.RFC3339)
row := s.store.DB.QueryRow(`
SELECT id, user_id, username, provider, token, created_at, expires_at, active
FROM auth_sessions
WHERE active = 1 AND expires_at > ?
ORDER BY id DESC
LIMIT 1
`, now)
return scanAuthSession(row)
}
func (s *AuthService) createSession(principal authPrincipal) (models.AuthSession, error) {
token, err := newSessionToken()
if err != nil {
return models.AuthSession{}, err
}
now := time.Now().UTC()
expiresAt := now.Add(24 * time.Hour)
tx, err := s.store.DB.Begin()
if err != nil {
return models.AuthSession{}, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.Exec("UPDATE auth_sessions SET active = 0 WHERE active = 1"); err != nil {
return models.AuthSession{}, err
}
result, err := tx.Exec(`
INSERT INTO auth_sessions (user_id, username, provider, token, created_at, expires_at, active)
VALUES (?, ?, ?, ?, ?, ?, 1)
`, principal.UserID, principal.Username, principal.Provider, token, now.Format(time.RFC3339), expiresAt.Format(time.RFC3339))
if err != nil {
return models.AuthSession{}, err
}
id, err := result.LastInsertId()
if err != nil {
return models.AuthSession{}, err
}
if err = tx.Commit(); err != nil {
return models.AuthSession{}, err
}
return models.AuthSession{
ID: id,
UserID: principal.UserID,
Username: principal.Username,
Provider: principal.Provider,
Token: token,
CreatedAt: now.Format(time.RFC3339),
ExpiresAt: expiresAt.Format(time.RFC3339),
Active: true,
}, nil
}
func (s *AuthService) recordAudit(actor string, action string, targetType string, targetID string, detail string) error {
if strings.TrimSpace(actor) == "" {
actor = "anonymous"
}
_, err := s.store.DB.Exec(`
INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`, actor, action, targetType, targetID, detail, time.Now().UTC().Format(time.RFC3339))
return err
}
func scanAuthSession(row *sql.Row) (models.AuthSession, error) {
var session models.AuthSession
var active int
err := row.Scan(
&session.ID,
&session.UserID,
&session.Username,
&session.Provider,
&session.Token,
&session.CreatedAt,
&session.ExpiresAt,
&active,
)
if errors.Is(err, sql.ErrNoRows) {
return models.AuthSession{}, nil
}
if err != nil {
return models.AuthSession{}, err
}
session.Active = active == 1
return session, nil
}
func newSessionToken() (string, error) {
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return "", err
}
return hex.EncodeToString(token), nil
}
type LocalAuthProvider struct {
store *SuiStore
}
func (p *LocalAuthProvider) Mode() string {
return authProviderLocal
}
func (p *LocalAuthProvider) Authenticate(username string, password string) (authPrincipal, error) {
config := NewAppConfigService(p.store)
expectedUsername, err := config.GetAppConfig("auth.local.username")
if err != nil {
return authPrincipal{}, err
}
salt, err := config.GetAppConfig("auth.local.password_salt")
if err != nil {
return authPrincipal{}, err
}
expectedHash, err := config.GetAppConfig("auth.local.password_hash")
if err != nil {
return authPrincipal{}, err
}
if username != expectedUsername || passwordHash(salt, password) != expectedHash {
return authPrincipal{}, errInvalidCredentials
}
return authPrincipal{
UserID: "local:" + username,
Username: username,
Provider: p.Mode(),
}, nil
}
type RemoteAuthProvider struct{}
func (p *RemoteAuthProvider) Mode() string {
return authProviderRemote
}
func (p *RemoteAuthProvider) Authenticate(username string, password string) (authPrincipal, error) {
return authPrincipal{}, errRemoteProviderNotReady
}
func passwordHash(salt string, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}