2026-07-08 23:37:19 +08:00
|
|
|
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"
|
|
|
|
|
}
|
2026-07-09 00:55:46 +08:00
|
|
|
return recordAuditLog(s.store, actor, action, targetType, targetID, detail)
|
2026-07-08 23:37:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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[:])
|
|
|
|
|
}
|