175 lines
6.5 KiB
Go
175 lines
6.5 KiB
Go
package repository
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"yingce/backend/internal/model"
|
|
)
|
|
|
|
var ErrProfileAuthorization = errors.New("账号或身份验证状态已变化,请重新登录或验证")
|
|
|
|
const ProfileReauthenticationTTL = 5 * time.Minute
|
|
|
|
func ProfileCredentialHash(passwordHash string) string {
|
|
v := sha256.Sum256([]byte(passwordHash))
|
|
return hex.EncodeToString(v[:])
|
|
}
|
|
|
|
func lockProfileUser(tx *gorm.DB, userID string) error {
|
|
if tx.Dialector.Name() == "sqlite" {
|
|
return tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("updated_at", gorm.Expr("updated_at")).Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Serialize session issuance with credential changes so a login validated
|
|
// before revocation cannot insert an authenticated session afterwards.
|
|
func (r *Repository) CreateAuthSessionForUser(expected *model.User, session *model.AuthSession, events ...*model.LoginLog) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
if session.UserID != expected.ID {
|
|
return ErrProfileAuthorization
|
|
}
|
|
if err := lockProfileUser(tx, expected.ID); err != nil {
|
|
return err
|
|
}
|
|
var current model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", expected.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
if current.Status != model.UserStatusActive || current.PasswordHash != expected.PasswordHash || current.Email != expected.Email {
|
|
return ErrProfileAuthorization
|
|
}
|
|
if err := tx.Create(session).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, event := range events {
|
|
if event.UserID == nil || *event.UserID != expected.ID || event.Event != "login" || event.Result != "success" {
|
|
return ErrProfileAuthorization
|
|
}
|
|
if err := tx.Create(event).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func profileAuthorization(tx *gorm.DB, expected *model.User, session *model.AuthSession, now time.Time, requireReauth bool) error {
|
|
var current model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", expected.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
if current.Status != model.UserStatusActive || current.PasswordHash != expected.PasswordHash || current.Email != expected.Email {
|
|
return ErrProfileAuthorization
|
|
}
|
|
var stored model.AuthSession
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&stored, "id = ? AND user_id = ?", session.ID, expected.ID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrProfileAuthorization
|
|
}
|
|
return err
|
|
}
|
|
// A transaction may wait for another account write; expiry is checked after
|
|
// taking the locks rather than against the request's earlier timestamp.
|
|
now = time.Now()
|
|
if stored.TokenHash != session.TokenHash || !stored.ExpiresAt.After(now) {
|
|
return ErrProfileAuthorization
|
|
}
|
|
if requireReauth && (stored.ReauthenticatedAt == nil || stored.ReauthenticatedAt.After(now) || !stored.ReauthenticatedAt.Add(ProfileReauthenticationTTL).After(now)) {
|
|
return ErrProfileAuthorization
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) ChangeProfilePassword(expected *model.User, session *model.AuthSession, newHash string, now time.Time) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := lockProfileUser(tx, expected.ID); err != nil {
|
|
return err
|
|
}
|
|
if err := profileAuthorization(tx, expected, session, now, expected.PasswordHash == ""); err != nil {
|
|
return err
|
|
}
|
|
result := tx.Model(&model.User{}).Where("id = ? AND password_hash = ? AND status = ?", expected.ID, expected.PasswordHash, model.UserStatusActive).Updates(map[string]any{"password_hash": newHash, "updated_at": now})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrProfileAuthorization
|
|
}
|
|
return tx.Where("user_id = ?", expected.ID).Delete(&model.AuthSession{}).Error
|
|
})
|
|
}
|
|
|
|
func (r *Repository) ChangeProfileEmail(expected *model.User, session *model.AuthSession, v *model.AuthVerification, now time.Time) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := lockRegistrationEmail(tx, v.Email); err != nil {
|
|
return err
|
|
}
|
|
if err := profileAuthorization(tx, expected, session, now, expected.PasswordHash == ""); err != nil {
|
|
return err
|
|
}
|
|
now = time.Now()
|
|
if err := New(tx).CheckEmailAvailable(v.Email, expected.ID); err != nil {
|
|
return err
|
|
}
|
|
result := tx.Model(&model.AuthVerification{}).Where("id = ? AND user_id = ? AND session_id = ? AND purpose = ? AND credential_hash = ? AND email = ? AND ready = ? AND used_at IS NULL AND expires_at > ? AND attempts <= 5", v.ID, expected.ID, session.ID, "profile_email", ProfileCredentialHash(expected.PasswordHash), v.Email, true, now).Update("used_at", now)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrVerificationInvalid
|
|
}
|
|
result = tx.Model(&model.User{}).Where("id = ? AND password_hash = ? AND status = ?", expected.ID, expected.PasswordHash, model.UserStatusActive).Updates(map[string]any{"email": v.Email, "email_verified_at": now, "updated_at": now})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrProfileAuthorization
|
|
}
|
|
return tx.Where("user_id = ?", expected.ID).Delete(&model.AuthSession{}).Error
|
|
})
|
|
}
|
|
|
|
func (r *Repository) ReauthenticateLinuxDOSession(expected *model.User, session *model.AuthSession, subject string, now time.Time) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := lockProfileUser(tx, expected.ID); err != nil {
|
|
return err
|
|
}
|
|
if err := profileAuthorization(tx, expected, session, now, false); err != nil {
|
|
return err
|
|
}
|
|
var count int64
|
|
if err := tx.Model(&model.UserIdentity{}).Where("provider = ? AND subject = ? AND user_id = ?", "linuxdo", subject, expected.ID).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count != 1 {
|
|
return ErrProfileAuthorization
|
|
}
|
|
result := tx.Model(&model.AuthSession{}).Where("id = ? AND user_id = ? AND token_hash = ?", session.ID, expected.ID, session.TokenHash).Updates(map[string]any{"reauthenticated_at": now, "updated_at": now})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrProfileAuthorization
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (r *Repository) LinuxDOReauthenticationState(stateHash string) (bool, error) {
|
|
var state model.OAuthState
|
|
err := r.db.First(&state, "provider = ? AND state_hash = ?", "linuxdo", stateHash).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return state.ReauthUserID != "" && state.ReauthSessionID != "", nil
|
|
}
|