mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
527fa47e0f
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) models.User no longer carries the secret. GetUserQ and its siblings, the member and backlink reads and the mention-recipient check therefore cannot fail on a secret that will not decrypt. checkSecondFactor loads it through the new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP branch (an empty one never verifies), a decrypt failure logs once without secret material and fails closed, and the recovery-code branch is untouched. Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1) - checkSecondFactor writes its 500 directly: writeInternalError logged the same error a second time. T2 counts every ERROR record except the request logger's per-request line. - T1's Stripe lookup now matches the undecryptable user (it looked up an unknown customer and could not go red), and ListUsers and SearchUsers are pinned to return the row with its 2FA flag. - The appstore boundary allowlist no longer says GetUserQ decrypts, and drops the deleted decryptUserTOTP. - Two comments no longer claim a single reader of the column. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2) An enabled row whose secret is empty cannot verify a code, so the user is held to recovery codes; checkSecondFactor now logs one warning naming the user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and the test placeholder helper drops its unused argument. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3) user.TOTPEnabled is the request's snapshot, and a disable landing after it also reads as no secret, so the warning no longer says 2FA is enabled; it says there was no secret to verify a code against. T5 asserts the new text. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019) The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty case wrote '' and returned at stored=="" first, and encrypt("") returns "" rather than sealing it, so dropping the guard left every test green. The store test now writes a hand-sealed empty plaintext to an enabled row and expects ErrNoTOTPSecret; a server test refuses a code computed from "" on that row. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5) EnableTOTP also decrypts the stored secret (to compare) and fails on one that will not decrypt, so the header no longer says the second-factor check is the only thing that fails. TOTPSecretForVerify remains the only reader that returns the plaintext. Comment only. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
217 lines
9.1 KiB
Go
217 lines
9.1 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// User represents a registered user in the system.
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"` // Unique handle; empty until set
|
|
Name string `json:"name"`
|
|
PasswordHash string `json:"-"` // Never serialized
|
|
Role string `json:"role"` // "admin" or "member"
|
|
AvatarURL string `json:"avatar_url,omitempty"`
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
|
RecoveryCodes string `json:"-"` // Never serialized
|
|
Plan string `json:"plan"` // "free", "pro", or "self-hosted"
|
|
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
|
|
PlanSource string `json:"-"` // Who set Plan: "manual" or "stripe" (TASK-3295); surfaced only on admin user responses
|
|
StripeCustomerID string `json:"-"` // Never serialized
|
|
PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits
|
|
OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"]
|
|
PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash)
|
|
// CredentialEpoch is bumped by every credential change (password reset or
|
|
// change, disable, account claim). A sign-in mints a session only if the
|
|
// epoch it read when checking the credential is still current (BUG-3382).
|
|
CredentialEpoch int64 `json:"-"`
|
|
// Kind is "human", or "app" for an installed app's bot principal
|
|
// (SPEC-6 §3, TASK-3392). Not serialized: no payload carries it.
|
|
Kind string `json:"-"`
|
|
DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled
|
|
EmailVerifiedAt string `json:"email_verified_at,omitempty"` // Non-empty = email verified (mirror DisabledAt). Empty/NULL = unverified. PLAN-1933 / TASK-1935.
|
|
LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request (any read or write)
|
|
LastWriteAt string `json:"last_write_at,omitempty"` // Last mutating action (item/comment/attachment); see Store.TouchUserWrite. PLAN-1542 / TASK-1543.
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// UserKindHuman and UserKindApp are the values of users.kind.
|
|
const (
|
|
UserKindHuman = "human"
|
|
UserKindApp = "app"
|
|
)
|
|
|
|
// IsApp reports whether the user is an installed app's bot principal. A bot
|
|
// can never sign in, hold a session, be claimed, be an admin or own anything.
|
|
func (u *User) IsApp() bool { return u != nil && u.Kind == UserKindApp }
|
|
|
|
// AppPrincipalEmailDomain is the reserved domain of every bot principal's
|
|
// email, app+<install-id>@apps.pad.invalid. .invalid can never resolve
|
|
// (RFC 2606), and no person may register, be invited at, or bootstrap with an
|
|
// address in it, so a bot's address can never be taken before its install
|
|
// creates it.
|
|
const AppPrincipalEmailDomain = "apps.pad.invalid"
|
|
|
|
// IsReservedAppEmail reports whether email is in the bot principals' domain,
|
|
// compared case-insensitively after trimming, as the users table stores it.
|
|
func IsReservedAppEmail(email string) bool {
|
|
e := strings.ToLower(strings.TrimSpace(email))
|
|
at := strings.LastIndexByte(e, '@')
|
|
return at >= 0 && e[at+1:] == AppPrincipalEmailDomain
|
|
}
|
|
|
|
// IsDisabled returns true if the user account has been disabled.
|
|
// PlanExpired reports whether the user's plan carries an expiry at or
|
|
// before now (BUG-3356). An expiry that does not parse as RFC3339 counts
|
|
// as expired: both write doors validate it, so such a value came from
|
|
// outside them, and an entitlement nobody can read is not one to grant.
|
|
func (u *User) PlanExpired(now time.Time) bool {
|
|
if u == nil || u.PlanExpiresAt == "" {
|
|
return false
|
|
}
|
|
t, err := time.Parse(time.RFC3339, u.PlanExpiresAt)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return !t.After(now)
|
|
}
|
|
|
|
// EffectivePlan is the plan entitlement is decided from: the stored plan,
|
|
// "free" when it is empty, and "free" once its expiry has passed
|
|
// (BUG-3356). The stored value is left alone, so an expired plan is still
|
|
// visible to admins as what it was.
|
|
func (u *User) EffectivePlan(now time.Time) string {
|
|
if u == nil || u.Plan == "" || u.PlanExpired(now) {
|
|
return "free"
|
|
}
|
|
return u.Plan
|
|
}
|
|
|
|
func (u *User) IsDisabled() bool {
|
|
return u.DisabledAt != ""
|
|
}
|
|
|
|
// IsEmailVerified returns true if the user's email address has been verified.
|
|
// Mirrors IsDisabled: a non-empty EmailVerifiedAt timestamp means verified,
|
|
// empty (NULL in the DB) means unverified. PLAN-1933 / TASK-1935.
|
|
func (u *User) IsEmailVerified() bool {
|
|
return u.EmailVerifiedAt != ""
|
|
}
|
|
|
|
// GetOAuthProviders parses the JSON oauth_providers field into a string slice.
|
|
func (u *User) GetOAuthProviders() []string {
|
|
if u.OAuthProviders == "" {
|
|
return nil
|
|
}
|
|
var providers []string
|
|
if err := json.Unmarshal([]byte(u.OAuthProviders), &providers); err != nil {
|
|
return nil
|
|
}
|
|
return providers
|
|
}
|
|
|
|
// HasOAuthProvider returns true if the user has linked the given provider.
|
|
func (u *User) HasOAuthProvider(provider string) bool {
|
|
for _, p := range u.GetOAuthProviders() {
|
|
if p == provider {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasPassword returns true if the user has explicitly set a password that
|
|
// they can sign in with. OAuth-only users have a random placeholder hash
|
|
// stored in PasswordHash which can't actually be used to log in, so this
|
|
// bit is tracked separately from PasswordHash being non-empty.
|
|
func (u *User) HasPassword() bool {
|
|
return u.PasswordSet
|
|
}
|
|
|
|
// UserCreate is the input for registering a new user.
|
|
type UserCreate struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username,omitempty"` // Optional; auto-generated if empty
|
|
Name string `json:"name"`
|
|
Password string `json:"password"` // Plaintext, will be hashed
|
|
Role string `json:"role,omitempty"` // Defaults to "member"
|
|
|
|
// Unverified requests that the created user start with an UNVERIFIED email
|
|
// (email_verified_at = NULL). The zero value (false) yields a VERIFIED user,
|
|
// so a call site that forgets to set it fails SAFE — verified, not
|
|
// write-locked (DR-3). The ONLY path that sets this true is the future
|
|
// cloud self-serve signup branch (PLAN-1933 Wave 3), which does not exist
|
|
// yet. Not settable via the request body (json:"-") — the handler decides.
|
|
Unverified bool `json:"-"`
|
|
}
|
|
|
|
// UserUpdate is the input for updating user profile fields.
|
|
type UserUpdate struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Username *string `json:"username,omitempty"`
|
|
Password *string `json:"password,omitempty"` // Plaintext, will be hashed
|
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
|
// ExpectedEpoch, when set with Password, makes the password write
|
|
// conditional on the account's credential_epoch still being this value:
|
|
// the epoch the caller's authority (the current password, a reset token)
|
|
// was checked under (BUG-3382). A mismatch writes nothing and returns
|
|
// store.ErrCredentialsChanged.
|
|
ExpectedEpoch *int64 `json:"-"`
|
|
}
|
|
|
|
// Session represents a database-backed authentication session.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
TokenHash string `json:"-"` // Never serialized
|
|
DeviceInfo string `json:"device_info,omitempty"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// WorkspaceInvitation represents a pending invitation to join a workspace.
|
|
type WorkspaceInvitation struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
InvitedBy string `json:"invited_by"`
|
|
Code string `json:"code"`
|
|
// Proof is the mailbox-only secret minted with the invitation
|
|
// (TASK-3352), set only on the struct CreateInvitation returns, for the
|
|
// email sender. It never serialises: the inviter must not be able to see
|
|
// it, because consuming it marks the invitee's address verified.
|
|
Proof string `json:"-"`
|
|
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// IsExpired reports whether an invitation is past its expiration window.
|
|
// Invitations created before the expires_at migration (nil ExpiresAt) are
|
|
// treated as non-expiring so existing codes don't break on upgrade.
|
|
func (inv *WorkspaceInvitation) IsExpired() bool {
|
|
if inv == nil || inv.ExpiresAt == nil {
|
|
return false
|
|
}
|
|
return time.Now().UTC().After(*inv.ExpiresAt)
|
|
}
|
|
|
|
// WorkspaceMember represents a user's membership in a workspace.
|
|
type WorkspaceMember struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
UserID string `json:"user_id"`
|
|
Role string `json:"role"` // "owner", "editor", or "viewer"
|
|
CollectionAccess string `json:"collection_access"` // "all" or "specific"
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// Populated by joins (not stored)
|
|
UserName string `json:"user_name,omitempty"`
|
|
UserEmail string `json:"user_email,omitempty"`
|
|
UserUsername string `json:"user_username,omitempty"`
|
|
}
|