Files
Agent-System-Backend/internal/store/one_time_password.go
T

264 lines
10 KiB
Go

package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"time"
)
const (
LoginKeyPurposeOneTimePassword = "one_time_password"
AuthMethodOneTimePassword = "one_time_password"
PasswordChangeModeOneTimePassword = "one_time_password"
oneTimePasswordTTL = 24 * time.Hour
)
// ErrPasswordRequired marks endpoints that require a prepared hash.
var ErrPasswordRequired = errors.New("store: password hash required")
// IssueOneTimePassword issues an administrator one-time login password without
// modifying the account's normal password.
func (s *Store) IssueOneTimePassword(actorOwner, owner, passwordHash string) (LoginKeyMeta, error) {
if passwordHash == "" {
return LoginKeyMeta{}, ErrPasswordRequired
}
if _, err := s.GetAccountByOwner(owner); err != nil {
return LoginKeyMeta{}, err
}
tx, err := s.db.Begin()
if err != nil {
return LoginKeyMeta{}, err
}
defer tx.Rollback()
locked, err := s.lockAccountUsers(tx, actorOwner, owner)
if errors.Is(err, ErrNotFound) {
return LoginKeyMeta{}, ErrAccountForbidden
}
if err != nil {
return LoginKeyMeta{}, err
}
actor, target := locked[actorOwner], locked[owner]
if !actor.Enabled || !actor.IsAdmin || !target.Enabled {
return LoginKeyMeta{}, ErrAccountForbidden
}
if target.IsSuperAdmin {
return LoginKeyMeta{}, ErrAccountForbidden
}
if err := requireSuperAdminTargetAccess(actor, target); err != nil {
return LoginKeyMeta{}, err
}
initialization := !target.HasPassword
if initialization && target.InitializationState == InitializationPending {
return LoginKeyMeta{}, ErrConflict
}
event, action := "one_time_password_issued", "account.one_time_password.issue"
if initialization {
event, action = "one_time_password_initialization_issued", "account.one_time_password.initialize"
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused); err != nil {
return LoginKeyMeta{}, err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND auth_method=?`), owner, AuthMethodOneTimePassword); err != nil {
return LoginKeyMeta{}, err
}
now := time.Now().UTC()
meta := LoginKeyMeta{
ID: newID("lk"),
Owner: owner,
Purpose: LoginKeyPurposeOneTimePassword,
Status: LoginKeyStatusUnused,
ExpiresAt: now.Add(oneTimePasswordTTL),
CreatedAt: now,
CreatedBy: actorOwner,
}
if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,grant_origin,generation,issued_auth_version)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
meta.ID, owner, meta.Purpose, passwordHash, meta.Status, meta.ExpiresAt.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
actorOwner, CredentialOriginAdminPassword, 0, target.AuthVersion); err != nil {
return LoginKeyMeta{}, err
}
if err := s.stampLoginKeyInstance(tx, meta.ID, ""); err != nil {
return LoginKeyMeta{}, err
}
if err := s.insertAccountEvent(tx, actorOwner, "admin-http", owner, event, meta.ID); err != nil {
return LoginKeyMeta{}, err
}
if err := s.auditAccountMutation(tx, owner, actorOwner, action); err != nil {
return LoginKeyMeta{}, err
}
if err := tx.Commit(); err != nil {
return LoginKeyMeta{}, err
}
return meta, nil
}
// LoginWithOneTimePassword verifies and consumes an unused one-time password.
func (s *Store) LoginWithOneTimePassword(owner, password string) (string, error) {
if owner == "" || password == "" {
return "", ErrBadCredentials
}
rows, err := s.db.Query(s.q(`SELECT k.id,k.key_hash,k.expires_at FROM login_keys k JOIN users u ON u.owner=k.owner
WHERE k.owner=? AND k.purpose=? AND k.status=? AND u.enabled=?`), owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused, true)
if err != nil {
return "", err
}
type candidate struct{ id, hash, expires string }
var candidates []candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.id, &c.hash, &c.expires); err != nil {
rows.Close()
return "", err
}
candidates = append(candidates, c)
}
if err := rows.Err(); err != nil {
rows.Close()
return "", err
}
rows.Close()
matched := ""
for _, c := range candidates {
expiry, err := time.Parse(time.RFC3339Nano, c.expires)
if err != nil || time.Now().After(expiry) {
continue
}
if VerifyPassword(c.hash, password) {
matched = c.id
break
}
}
if matched == "" {
return "", ErrBadCredentials
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "sess_" + hex.EncodeToString(raw)
err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.one_time_password.login"}, func(tx *sql.Tx, _ string) error {
locked, err := s.lockAccountUsers(tx, owner)
if err != nil {
return err
}
account := locked[owner]
if !account.Enabled {
return ErrAccountForbidden
}
query := s.q(`SELECT owner,purpose,status,expires_at,issued_auth_version FROM login_keys WHERE id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var keyOwner, purpose, status, expires string
var issuedVersion int
if err := tx.QueryRow(query, matched).Scan(&keyOwner, &purpose, &status, &expires, &issuedVersion); err != nil {
return ErrConflict
}
expiry, err := time.Parse(time.RFC3339Nano, expires)
if err != nil || time.Now().After(expiry) || keyOwner != owner || purpose != LoginKeyPurposeOneTimePassword ||
status != LoginKeyStatusUnused || issuedVersion != account.AuthVersion {
return ErrConflict
}
res, err := tx.Exec(s.q(`UPDATE login_keys SET status=?,consumed_at=? WHERE id=? AND status=?`),
LoginKeyStatusConsumed, nowISO(), matched, LoginKeyStatusUnused)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil || n != 1 {
return ErrConflict
}
if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin,grant_id)
VALUES(?,?,?,?,?,?,?,?,?,?)`),
hashKey(token), owner, nowISO(), time.Now().Add(restrictedSessionTTL).UTC().Format(time.RFC3339Nano), nowISO(),
SessionScopeSetPasswordOnly, account.AuthVersion, AuthMethodOneTimePassword, account.CredentialOrigin, matched); err != nil {
return err
}
return s.insertAccountEvent(tx, owner, "one-time-password", owner, "one_time_password_login", matched)
})
if err != nil {
return "", err
}
return token, nil
}
// CompleteOneTimePasswordChecked finalizes a one-time password restricted session.
func (s *Store) CompleteOneTimePasswordChecked(token, passwordHash string) (string, error) {
if passwordHash == "" {
return "", ErrPasswordRequired
}
var owner string
if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
err := s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.one_time_password"}, func(tx *sql.Tx, _ string) error {
locked, err := s.lockAccountUsers(tx, owner)
if err != nil {
return err
}
account := locked[owner]
if !account.Enabled {
return ErrAccountForbidden
}
sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,grant_id FROM sessions WHERE token_hash=?`)
grantQuery := s.q(`SELECT owner,purpose,status,issued_auth_version FROM login_keys WHERE id=?`)
if s.Dialect() == "postgres" {
sessionQuery += ` FOR UPDATE`
grantQuery += ` FOR UPDATE`
}
var sessionOwner, scope, expires, method, grantID string
var sessionVersion int
if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &expires, &sessionVersion, &method, &grantID); err != nil {
return ErrNotFound
}
expiry, err := time.Parse(time.RFC3339Nano, expires)
if err != nil || time.Now().After(expiry) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly ||
method != AuthMethodOneTimePassword || sessionVersion != account.AuthVersion || grantID == "" {
return ErrAccountForbidden
}
var grantOwner, purpose, status string
var issuedVersion int
if err := tx.QueryRow(grantQuery, grantID).Scan(&grantOwner, &purpose, &status, &issuedVersion); err != nil {
return ErrAccountForbidden
}
if grantOwner != owner || purpose != LoginKeyPurposeOneTimePassword || status != LoginKeyStatusConsumed || issuedVersion != account.AuthVersion {
return ErrAccountForbidden
}
update := `UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,credential_origin=?
WHERE owner=? AND auth_version=? AND enabled=?`
args := []any{passwordHash, nowISO(), false, CredentialOriginAdminPassword, owner, account.AuthVersion, true}
if !account.HasPassword {
update = `UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,credential_origin=?,
initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=? AND password_hash=''`
args = []any{passwordHash, nowISO(), false, CredentialOriginAdminPassword, InitializationInitialized, owner, account.AuthVersion, true}
}
res, err := tx.Exec(s.q(update), args...)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil || n != 1 {
return ErrConflict
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
return s.insertAccountEvent(tx, owner, "one-time-password", owner, "password_set", LoginKeyPurposeOneTimePassword+" "+grantID)
})
return owner, err
}
// InvalidateUnusedOneTimePasswords revokes outstanding unused one-time passwords.
func (s *Store) InvalidateUnusedOneTimePasswords(owner string) error {
_, err := s.db.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused)
return err
}