264 lines
10 KiB
Go
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
|
|
}
|