258 lines
9.1 KiB
Go
258 lines
9.1 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
|
|
mysqldriver "github.com/go-sql-driver/mysql"
|
|
"hyapp/pkg/xerr"
|
|
authdomain "hyapp/services/user-service/internal/domain/auth"
|
|
userdomain "hyapp/services/user-service/internal/domain/user"
|
|
)
|
|
|
|
// SetPassword 为已有用户首次写入密码身份。
|
|
func (r *Repository) SetPassword(ctx context.Context, account authdomain.PasswordAccount) error {
|
|
_, err := r.db.ExecContext(ctx, `
|
|
INSERT INTO password_accounts (user_id, password_hash, hash_alg, created_at_ms, updated_at_ms)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, account.UserID, account.PasswordHash, account.HashAlg, account.CreatedAtMs, account.UpdatedAtMs)
|
|
if err != nil {
|
|
return mapAuthDuplicateError(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FindPasswordByDisplayUserID 通过当前 active display_user_id 查找用户密码身份。
|
|
func (r *Repository) FindPasswordByDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (authdomain.PasswordAccount, error) {
|
|
if err := r.expirePrettyByDisplayUserID(ctx, displayUserID, nowMs, "lazy_password_login"); err != nil {
|
|
return authdomain.PasswordAccount{}, err
|
|
}
|
|
|
|
var account authdomain.PasswordAccount
|
|
err := r.db.QueryRowContext(ctx, `
|
|
SELECT pa.user_id, u.current_display_user_id, pa.password_hash, pa.hash_alg, pa.created_at_ms, pa.updated_at_ms
|
|
FROM users u
|
|
INNER JOIN password_accounts pa ON pa.user_id = u.user_id
|
|
WHERE u.current_display_user_id = ?
|
|
`, displayUserID).Scan(&account.UserID, &account.DisplayUserID, &account.PasswordHash, &account.HashAlg, &account.CreatedAtMs, &account.UpdatedAtMs)
|
|
if err == sql.ErrNoRows {
|
|
return authdomain.PasswordAccount{}, xerr.New(xerr.NotFound, "password account not found")
|
|
}
|
|
if err != nil {
|
|
return authdomain.PasswordAccount{}, err
|
|
}
|
|
|
|
return account, nil
|
|
}
|
|
|
|
// CreateSession 创建新的 refresh session。
|
|
func (r *Repository) CreateSession(ctx context.Context, session authdomain.Session) error {
|
|
_, err := r.db.ExecContext(ctx, `
|
|
INSERT INTO auth_sessions (session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, created_at_ms, updated_at_ms)
|
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
|
|
`, session.SessionID, session.UserID, session.RefreshTokenHash, session.DeviceID, session.ExpiresAtMs, session.CreatedAtMs, session.UpdatedAtMs)
|
|
if err != nil {
|
|
return mapAuthDuplicateError(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FindActiveSessionByRefreshHash 查找未过期且未吊销的 session。
|
|
func (r *Repository) FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error) {
|
|
session, err := r.findSessionByRefreshHash(ctx, refreshTokenHash)
|
|
if err != nil {
|
|
return authdomain.Session{}, err
|
|
}
|
|
if session.RevokedAtMs > 0 {
|
|
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
|
|
}
|
|
if session.ExpiresAtMs <= nowMs {
|
|
return authdomain.Session{}, xerr.New(xerr.SessionExpired, "session expired")
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// ReplaceSession 原子吊销旧 session 并创建新 session。
|
|
func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error {
|
|
tx, err := r.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var oldRevokedAt sql.NullInt64
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT revoked_at_ms
|
|
FROM auth_sessions
|
|
WHERE session_id = ?
|
|
FOR UPDATE
|
|
`, oldSessionID).Scan(&oldRevokedAt)
|
|
if err == sql.ErrNoRows || oldRevokedAt.Valid {
|
|
return xerr.New(xerr.SessionRevoked, "session revoked")
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE auth_sessions
|
|
SET revoked_at_ms = ?, updated_at_ms = ?
|
|
WHERE session_id = ?
|
|
`, revokedAtMs, revokedAtMs, oldSessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := insertSession(ctx, tx, newSession); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// RevokeSession 按 session_id 或 refresh token hash 吊销 session。
|
|
func (r *Repository) RevokeSession(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64) (bool, error) {
|
|
if sessionID == "" && refreshTokenHash != "" {
|
|
session, err := r.findSessionByRefreshHash(ctx, refreshTokenHash)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
sessionID = session.SessionID
|
|
}
|
|
|
|
result, err := r.db.ExecContext(ctx, `
|
|
UPDATE auth_sessions
|
|
SET revoked_at_ms = ?, updated_at_ms = ?
|
|
WHERE session_id = ? AND revoked_at_ms IS NULL
|
|
`, revokedAtMs, revokedAtMs, sessionID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return affected > 0, nil
|
|
}
|
|
|
|
// FindThirdPartyIdentity 查找 provider + subject 绑定。
|
|
func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error) {
|
|
var identity authdomain.ThirdPartyIdentity
|
|
err := r.db.QueryRowContext(ctx, `
|
|
SELECT user_id, provider, provider_subject, COALESCE(provider_union_id, ''), created_at_ms, updated_at_ms
|
|
FROM third_party_identities
|
|
WHERE provider = ? AND provider_subject = ?
|
|
`, provider, providerSubject).Scan(&identity.UserID, &identity.Provider, &identity.ProviderSubject, &identity.ProviderUnionID, &identity.CreatedAtMs, &identity.UpdatedAtMs)
|
|
if err == sql.ErrNoRows {
|
|
return authdomain.ThirdPartyIdentity{}, xerr.New(xerr.NotFound, "third-party identity not found")
|
|
}
|
|
if err != nil {
|
|
return authdomain.ThirdPartyIdentity{}, err
|
|
}
|
|
|
|
return identity, nil
|
|
}
|
|
|
|
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
|
|
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error {
|
|
tx, err := r.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if err := insertUserIdentity(ctx, tx, user, identity); err != nil {
|
|
return mapAuthDuplicateError(err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO third_party_identities (user_id, provider, provider_subject, provider_union_id, created_at_ms, updated_at_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, thirdParty.UserID, thirdParty.Provider, thirdParty.ProviderSubject, nullableString(thirdParty.ProviderUnionID), thirdParty.CreatedAtMs, thirdParty.UpdatedAtMs)
|
|
if err != nil {
|
|
return mapAuthDuplicateError(err)
|
|
}
|
|
|
|
if err := insertSession(ctx, tx, session); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// RecordLoginAudit 写登录审计;调用方不会让审计失败影响主链路。
|
|
func (r *Repository) RecordLoginAudit(ctx context.Context, audit authdomain.LoginAudit) error {
|
|
_, err := r.db.ExecContext(ctx, `
|
|
INSERT INTO login_audit (request_id, user_id, login_type, provider, result, failure_code, client_ip, user_agent, created_at_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, audit.RequestID, nullableUserID(audit.UserID), audit.LoginType, nullableString(audit.Provider), audit.Result, nullableString(audit.FailureCode), nullableString(audit.ClientIP), nullableString(audit.UserAgent), audit.CreatedAtMs)
|
|
return err
|
|
}
|
|
|
|
func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (authdomain.Session, error) {
|
|
var session authdomain.Session
|
|
var revokedAt sql.NullInt64
|
|
err := r.db.QueryRowContext(ctx, `
|
|
SELECT session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, created_at_ms, updated_at_ms
|
|
FROM auth_sessions
|
|
WHERE refresh_token_hash = ?
|
|
`, refreshTokenHash).Scan(&session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &revokedAt, &session.CreatedAtMs, &session.UpdatedAtMs)
|
|
if err == sql.ErrNoRows {
|
|
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
|
|
}
|
|
if err != nil {
|
|
return authdomain.Session{}, err
|
|
}
|
|
if revokedAt.Valid {
|
|
session.RevokedAtMs = revokedAt.Int64
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
func insertSession(ctx context.Context, tx *sql.Tx, session authdomain.Session) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO auth_sessions (session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, created_at_ms, updated_at_ms)
|
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
|
|
`, session.SessionID, session.UserID, session.RefreshTokenHash, session.DeviceID, session.ExpiresAtMs, session.CreatedAtMs, session.UpdatedAtMs)
|
|
if err != nil {
|
|
return mapAuthDuplicateError(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func mapAuthDuplicateError(err error) error {
|
|
var mysqlErr *mysqldriver.MySQLError
|
|
if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1062 {
|
|
return err
|
|
}
|
|
|
|
message := mysqlErr.Message
|
|
switch {
|
|
case strings.Contains(message, "password_accounts"):
|
|
return xerr.New(xerr.PasswordAlreadySet, "password already set")
|
|
case strings.Contains(message, "third_party") || strings.Contains(message, "uk_third_party_provider_subject"):
|
|
return xerr.New(xerr.Conflict, "third-party identity already exists")
|
|
case strings.Contains(message, "display") || strings.Contains(message, "uk_users_current_display_user_id"):
|
|
return xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists")
|
|
default:
|
|
return xerr.New(xerr.Conflict, "duplicate key")
|
|
}
|
|
}
|
|
|
|
func nullableUserID(userID int64) any {
|
|
if userID <= 0 {
|
|
return nil
|
|
}
|
|
|
|
return userID
|
|
}
|