feat: add auth provider boundary
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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[:])
|
||||
}
|
||||
@@ -81,6 +81,27 @@ func Migrate(db *sql.DB) error {
|
||||
description TEXT,
|
||||
state INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -62,3 +63,96 @@ func TestAppConfigServiceLanguage(t *testing.T) {
|
||||
t.Fatalf("updated language = %q, want en", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceLocalLoginLifecycle(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewAuthService(store)
|
||||
|
||||
session, err := service.Login("local", "admin", "admin123")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if !session.Active {
|
||||
t.Fatal("session is not active")
|
||||
}
|
||||
if session.Username != "admin" {
|
||||
t.Fatalf("username = %q, want admin", session.Username)
|
||||
}
|
||||
if session.Provider != "local" {
|
||||
t.Fatalf("provider = %q, want local", session.Provider)
|
||||
}
|
||||
if session.Token == "" {
|
||||
t.Fatal("session token is empty")
|
||||
}
|
||||
|
||||
current, err := service.GetCurrentSession()
|
||||
if err != nil {
|
||||
t.Fatalf("get current session: %v", err)
|
||||
}
|
||||
if current.ID != session.ID {
|
||||
t.Fatalf("current session id = %d, want %d", current.ID, session.ID)
|
||||
}
|
||||
|
||||
var loginAuditCount int
|
||||
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.success' AND actor = 'admin'").Scan(&loginAuditCount); err != nil {
|
||||
t.Fatalf("count login audit logs: %v", err)
|
||||
}
|
||||
if loginAuditCount != 1 {
|
||||
t.Fatalf("login audit count = %d, want 1", loginAuditCount)
|
||||
}
|
||||
|
||||
if err := service.Logout(); err != nil {
|
||||
t.Fatalf("logout: %v", err)
|
||||
}
|
||||
current, err = service.GetCurrentSession()
|
||||
if err != nil {
|
||||
t.Fatalf("get current session after logout: %v", err)
|
||||
}
|
||||
if current.Active {
|
||||
t.Fatal("current session is active after logout")
|
||||
}
|
||||
|
||||
var logoutAuditCount int
|
||||
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.logout' AND actor = 'admin'").Scan(&logoutAuditCount); err != nil {
|
||||
t.Fatalf("count logout audit logs: %v", err)
|
||||
}
|
||||
if logoutAuditCount != 1 {
|
||||
t.Fatalf("logout audit count = %d, want 1", logoutAuditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceLocalLoginFailureWritesAuditLog(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewAuthService(store)
|
||||
|
||||
_, err := service.Login("local", "admin", "bad-password")
|
||||
if !errors.Is(err, errInvalidCredentials) {
|
||||
t.Fatalf("login error = %v, want invalid credentials", err)
|
||||
}
|
||||
|
||||
var auditCount int
|
||||
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.failure' AND actor = 'admin'").Scan(&auditCount); err != nil {
|
||||
t.Fatalf("count audit logs: %v", err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Fatalf("audit count = %d, want 1", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceRemoteProviderReserved(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewAuthService(store)
|
||||
|
||||
_, err := service.Login("remote", "admin", "admin123")
|
||||
if !errors.Is(err, errRemoteProviderNotReady) {
|
||||
t.Fatalf("login error = %v, want remote provider not ready", err)
|
||||
}
|
||||
|
||||
var auditCount int
|
||||
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.failure' AND target_id = 'remote'").Scan(&auditCount); err != nil {
|
||||
t.Fatalf("count audit logs: %v", err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Fatalf("audit count = %d, want 1", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,17 @@ func defaultHotkeysSQL() string {
|
||||
func defaultAppConfigSQL() string {
|
||||
return `
|
||||
INSERT OR IGNORE INTO appconfig (key, type, value, description)
|
||||
VALUES ('language', 'system', 'zh', '应用语言,支持 zh/en');`
|
||||
VALUES ('language', 'system', 'zh', '应用语言,支持 zh/en');
|
||||
|
||||
INSERT OR IGNORE INTO appconfig (key, type, value, description)
|
||||
VALUES ('auth.provider', 'system', 'local', '登录 provider,支持 local/remote');
|
||||
|
||||
INSERT OR IGNORE INTO appconfig (key, type, value, description)
|
||||
VALUES ('auth.local.username', 'system', 'admin', '本地演示账号用户名');
|
||||
|
||||
INSERT OR IGNORE INTO appconfig (key, type, value, description)
|
||||
VALUES ('auth.local.password_salt', 'system', 'cmbone-local-auth-v1', '本地演示账号密码盐');
|
||||
|
||||
INSERT OR IGNORE INTO appconfig (key, type, value, description)
|
||||
VALUES ('auth.local.password_hash', 'system', '3e0bfcda604b19fef1f596bcade7469ff1ab8b427fdc998e9a8216101b002f4a', '本地演示账号密码哈希');`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user