services

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 24, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package services holds the goravel-authkit business logic: credential verification, password changes with other-session invalidation, user management, and audit writes. Services never touch the HTTP session — that is the controller's concern; they only verify credentials and mutate tables.

Passwords are hashed via the Goravel Hash facade (configure bcrypt cost 12 in config/hashing.go). No password or hash is ever logged.

Index

Constants

View Source
const AdminRole = "admin"

AdminRole is the canonical privileged role. It is the fail-closed default for the /users management gate and the bootstrap (auth:create-user) admin.

View Source
const DefaultMinPasswordLength = 8

DefaultMinPasswordLength is the fallback minimum new-password length when the app does not override authkit.min_password_length.

View Source
const DefaultRecoveryCodeCount = 8

DefaultRecoveryCodeCount is the fallback number of recovery codes generated on confirmation when the app does not override authkit.two_factor.recovery_codes.

View Source
const DefaultRememberLifetime = 30 * 24 * time.Hour

DefaultRememberLifetime is the fallback validity window for a remember cookie.

View Source
const DefaultRole = "user"

DefaultRole is the non-privileged role assigned to users created without an explicit role when no safer configured role can be derived. It must never be a management/admin role: new users default to the least privilege.

View Source
const DefaultSessionActiveWindow = 2 * time.Hour

DefaultSessionActiveWindow bounds how recently a session must have been active to count as "current" — mirrors the default session lifetime.

View Source
const DefaultTwoFactorIssuer = "goravel-authkit"

DefaultTwoFactorIssuer is the fallback issuer shown in the authenticator app.

View Source
const MaxPasswordBytes = 72

MaxPasswordBytes caps accepted passwords at bcrypt's hard limit: bcrypt only consumes the first 72 bytes, so anything longer is silently truncated (two distinct long passwords sharing a 72-byte prefix would compare equal). Rejecting oversize input up front makes that truncation impossible.

Variables

View Source
var (
	// ErrInvalidCredentials is returned for both unknown-email and wrong-password
	// so the response never reveals which.
	ErrInvalidCredentials = errors.New("invalid credentials")
	// ErrValidation is a generic input-validation failure; wrap it with a
	// human-readable message via errors.Join for detail.
	ErrValidation = errors.New("validation error")
	// ErrWrongPassword is returned when the supplied current password is wrong.
	ErrWrongPassword = errors.New("wrong password")
	// ErrNotFound is returned when a requested user does not exist.
	ErrNotFound = errors.New("not found")
	// ErrUnauthorized is returned when the authenticated user no longer exists.
	ErrUnauthorized = errors.New("unauthorized")
	// ErrAlreadyExists is returned when an email collides with an existing user.
	ErrAlreadyExists = errors.New("already exists")
	// ErrLastAdmin is returned when an operation (delete, disable, or demote)
	// would leave no active user holding a management/admin role.
	ErrLastAdmin = errors.New("cannot remove the last admin")
	// ErrInternal wraps an unexpected lower-layer failure.
	ErrInternal = errors.New("internal error")
	// ErrTwoFactorNotEnrolled is returned when a 2FA operation needs an active
	// (or pending) enrollment the user does not have.
	ErrTwoFactorNotEnrolled = errors.New("two-factor not enrolled")
	// ErrTwoFactorAlreadyEnabled is returned when enrolling a user who already
	// has 2FA confirmed.
	ErrTwoFactorAlreadyEnabled = errors.New("two-factor already enabled")
	// ErrInvalidCode is returned when a TOTP (or recovery) code does not verify.
	ErrInvalidCode = errors.New("invalid code")
	// ErrInvalidRememberToken is returned when a "remember me" cookie is malformed,
	// unknown, expired, or fails validation.
	ErrInvalidRememberToken = errors.New("invalid remember token")
)

Sentinel errors. Controllers map these to HTTP status codes via errors.Is.

View Source
var LoginActions = []string{"auth.login", "auth.login_remember"}

LoginActions are the audit actions that count as a successful sign-in.

Functions

This section is empty.

Types

type Audit

type Audit struct {
	// contains filtered or unexported fields
}

Audit is the single chokepoint for writing audit entries.

func NewAudit

func NewAudit(repo repositories.AuditRepository) *Audit

NewAudit builds the audit service.

func (*Audit) Log

func (s *Audit) Log(ctx context.Context, e AuditEntry) error

Log writes an audit entry. Audit is best-effort context for operators; callers decide whether a failure here should fail the parent operation.

func (*Audit) RecentLogins

func (s *Audit) RecentLogins(ctx context.Context, userID uuid.UUID, limit int) ([]models.AuditLog, error)

RecentLogins returns the user's most recent successful sign-ins (password or remember-cookie), newest first, capped at limit.

type AuditEntry

type AuditEntry struct {
	ActorID      *uuid.UUID
	ActorEmail   string
	Action       string
	ResourceType string
	ResourceID   *string
	Metadata     map[string]any
	IP           string
}

AuditEntry is the input to the audit service — shaped like a future event payload so audit can move to an event/queue pipeline later without changing callers.

type Auth

type Auth struct {
	// contains filtered or unexported fields
}

Auth encapsulates credential verification and self-service password changes. Session establishment itself is the controller's concern.

func NewAuth

func NewAuth(repo repositories.UsersRepository, hasher Hasher, minPwLen int) *Auth

NewAuth builds the auth service. minPwLen <= 0 falls back to DefaultMinPasswordLength.

func (*Auth) Authenticate

func (s *Auth) Authenticate(ctx context.Context, email, password string) (*models.User, error)

Authenticate verifies credentials and returns the matching user. A generic ErrInvalidCredentials is returned for both unknown-email and wrong-password.

func (*Auth) ChangePassword

func (s *Auth) ChangePassword(ctx context.Context, id uuid.UUID, currentPassword, newPassword string) (time.Time, error)

ChangePassword verifies the current password, validates the new one (min length, must differ), updates the hash, and bumps password_changed_at so every OTHER active session for this user is rejected on its next request. It returns the new password_changed_at — re-read from the DB so it matches the stored precision exactly — so the controller can re-stamp THIS session and keep it alive. (A locally-generated time.Now() has nanosecond precision, but Postgres timestamptz stores microseconds, so stamping the raw value would never match the DB value on the next request and would log THIS session out.)

func (*Auth) Me

func (s *Auth) Me(ctx context.Context, id uuid.UUID) (*models.User, error)

Me returns the authenticated user, or ErrUnauthorized if it no longer exists or has been disabled. The disabled check mirrors the Authenticated middleware so the method is safe to use as a standalone "load current user" primitive.

func (*Auth) UpdateProfile

func (s *Auth) UpdateProfile(ctx context.Context, id uuid.UUID, email, name string) (*models.User, bool, error)

UpdateProfile lets the authenticated user change their own name and email. The role is intentionally out of scope (admin-managed via the users service). It returns the updated user and whether anything actually changed (false skips the write so a no-op save isn't audited). A colliding email returns ErrAlreadyExists; a missing email returns ErrValidation. Changing the email clears EmailVerified, since the new address is unverified.

type Crypter

type Crypter interface {
	Encrypt(value string) (string, error)
	Decrypt(payload string) (string, error)
}

Crypter abstracts symmetric encryption so the two-factor service can be unit-tested without booting the framework. The default implementation delegates to the Goravel Crypt facade (which uses the app key).

type Enrollment

type Enrollment struct {
	Secret     string
	OtpauthURL string
}

Enrollment is returned when a user starts TOTP enrollment: the secret and the otpauth:// URL for rendering a QR code.

type FacadeCrypter

type FacadeCrypter struct{}

FacadeCrypter is the production Crypter, backed by facades.Crypt().

func NewFacadeCrypter

func NewFacadeCrypter() FacadeCrypter

NewFacadeCrypter returns a Crypter backed by the Goravel Crypt facade.

func (FacadeCrypter) Decrypt

func (FacadeCrypter) Decrypt(payload string) (string, error)

func (FacadeCrypter) Encrypt

func (FacadeCrypter) Encrypt(value string) (string, error)

type FacadeHasher

type FacadeHasher struct{}

FacadeHasher is the production Hasher, backed by facades.Hash().

func NewFacadeHasher

func NewFacadeHasher() FacadeHasher

NewFacadeHasher returns a Hasher backed by the Goravel Hash facade.

func (FacadeHasher) Check

func (FacadeHasher) Check(value, hashed string) bool

func (FacadeHasher) Make

func (FacadeHasher) Make(value string) (string, error)

type Hasher

type Hasher interface {
	Make(value string) (string, error)
	Check(value, hashed string) bool
}

Hasher abstracts password hashing so services can be unit-tested without booting the framework. The default implementation delegates to the Goravel Hash facade (configure bcrypt cost 12 in config/hashing.go).

type Remember

type Remember struct {
	// contains filtered or unexported fields
}

Remember implements persistent "remember me" login via the selector-validator pattern. The cookie value is "selector:validator"; the store keeps the selector in clear (indexed) and only a SHA-256 hash of the validator. The validator is rotated on every successful use.

func NewRemember

func NewRemember(repo repositories.RememberRepository, ttl time.Duration) *Remember

NewRemember builds the remember service. ttl <= 0 falls back to DefaultRememberLifetime.

func (*Remember) Issue

func (s *Remember) Issue(ctx context.Context, userID uuid.UUID) (string, error)

Issue creates a new remember token for the user and returns the cookie value ("selector:validator") to set on the response.

func (*Remember) PruneExpired

func (s *Remember) PruneExpired(ctx context.Context) error

PruneExpired deletes every remember token whose expiry has passed. Intended to be run periodically (a scheduled task / the auth:prune-remember-tokens command) since abandoned cookies are otherwise only cleaned up lazily on access.

func (*Remember) Resolve

func (s *Remember) Resolve(ctx context.Context, cookieValue string) (uuid.UUID, string, error)

Resolve validates a remember cookie value and returns the user id plus, when the validator was rotated, a fresh cookie value the caller must set (empty means "leave the cookie unchanged"). Behaviour:

  • current validator → rotate via an atomic compare-and-set and return the new cookie value. If a concurrent request rotated first (0 rows updated), authenticate anyway but issue no new cookie (the other response set it).
  • the just-superseded validator within the grace window → accept (a concurrent request still carrying it), without rotating or revoking.
  • anything else (unknown/expired/wrong/too-old) → ErrInvalidRememberToken; a known selector with a stale-beyond-grace validator is treated as theft and revokes the user's whole token family.

func (*Remember) Revoke

func (s *Remember) Revoke(ctx context.Context, cookieValue string) error

Revoke deletes the token identified by the cookie value's selector (logout of this device). A malformed value or unknown selector is a no-op.

func (*Remember) RevokeAllForUser

func (s *Remember) RevokeAllForUser(ctx context.Context, userID uuid.UUID) error

RevokeAllForUser deletes every remember token for a user (e.g. on a password change, to force re-authentication everywhere).

func (*Remember) TTL

func (s *Remember) TTL() time.Duration

TTL is the configured validity window (used by the controller to size the cookie's Max-Age).

type SessionView

type SessionView struct {
	ID           uuid.UUID
	IP           string
	UserAgent    string
	CreatedAt    time.Time
	LastActiveAt time.Time
	IsCurrent    bool
}

SessionView is the service-level projection of a tracked session, with the current-session marker resolved (the secret session id is never exposed).

type Sessions

type Sessions struct {
	// contains filtered or unexported fields
}

Sessions manages the active-session list and termination. It tracks one row per login; deleting a row terminates that session (its next request is rejected by the TrackSession middleware).

func NewSessions

func NewSessions(repo repositories.SessionRepository, activeWindow time.Duration) *Sessions

NewSessions builds the sessions service. activeWindow <= 0 falls back to DefaultSessionActiveWindow; pass the configured session lifetime so the list hides sessions whose store entry has already expired.

func (*Sessions) Forget

func (s *Sessions) Forget(ctx context.Context, sessionID string) error

Forget removes the row for a session id (used on logout).

func (*Sessions) ForgetAllForUser

func (s *Sessions) ForgetAllForUser(ctx context.Context, userID uuid.UUID) error

ForgetAllForUser removes every session row for a user (e.g. password change).

func (*Sessions) List

func (s *Sessions) List(ctx context.Context, userID uuid.UUID, currentSessionID string) ([]SessionView, error)

List returns the user's active sessions (most recent first), marking the one that matches currentSessionID.

func (*Sessions) PruneStale

func (s *Sessions) PruneStale(ctx context.Context) error

PruneStale deletes session rows whose store entry has certainly expired.

func (*Sessions) Terminate

func (s *Sessions) Terminate(ctx context.Context, userID, publicID uuid.UUID, currentSessionID string) error

Terminate deletes one of the user's sessions by its public id. It refuses to terminate the current session (use logout) and returns ErrNotFound when the id is unknown or belongs to another user.

func (*Sessions) TerminateOthers

func (s *Sessions) TerminateOthers(ctx context.Context, userID uuid.UUID, currentSessionID string) error

TerminateOthers deletes every session for the user except the current one.

func (*Sessions) Touch

func (s *Sessions) Touch(ctx context.Context, sessionID string) (bool, error)

Touch refreshes a session's last-active time and reports whether it still exists (false means the row was deleted → the session was terminated).

func (*Sessions) Track

func (s *Sessions) Track(ctx context.Context, sessionID string, userID uuid.UUID, ip, userAgent string) error

Track records the row for a freshly established session (login / remember login). It replaces any existing row for the same session id.

type TwoFactor

type TwoFactor struct {
	// contains filtered or unexported fields
}

TwoFactor orchestrates TOTP enrollment, verification, and recovery codes. The secret and recovery codes are stored encrypted via the Crypter.

func NewTwoFactor

func NewTwoFactor(repo repositories.UsersRepository, crypter Crypter, issuer string, recoveryCount int) *TwoFactor

NewTwoFactor builds the two-factor service. Empty issuer / non-positive recoveryCount fall back to the package defaults.

func (*TwoFactor) Confirm

func (s *TwoFactor) Confirm(ctx context.Context, id uuid.UUID, code string) ([]string, error)

Confirm verifies a code against the pending secret, activates 2FA, and returns freshly generated recovery codes (shown to the user once).

func (*TwoFactor) ConsumeRecoveryCode

func (s *TwoFactor) ConsumeRecoveryCode(ctx context.Context, id uuid.UUID, code string) (bool, error)

ConsumeRecoveryCode verifies a recovery code and marks it used (single-use). The read-marked-write is atomic under a row lock to prevent double-spend.

func (*TwoFactor) Disable

func (s *TwoFactor) Disable(ctx context.Context, id uuid.UUID) error

Disable clears all two-factor state for the user.

func (*TwoFactor) Enable

func (s *TwoFactor) Enable(ctx context.Context, id uuid.UUID) (*Enrollment, error)

Enable starts enrollment: it generates a TOTP secret, stores it encrypted (not yet confirmed), and returns the secret + otpauth URL for the user to scan. Re-enrolling a confirmed user returns ErrTwoFactorAlreadyEnabled.

func (*TwoFactor) RegenerateRecoveryCodes

func (s *TwoFactor) RegenerateRecoveryCodes(ctx context.Context, id uuid.UUID) ([]string, error)

RegenerateRecoveryCodes replaces the recovery codes, invalidating the old set.

func (*TwoFactor) RemainingRecoveryCodes

func (s *TwoFactor) RemainingRecoveryCodes(ctx context.Context, id uuid.UUID) (int, error)

RemainingRecoveryCodes returns how many unused recovery codes the user has left. Plaintext codes are never returned after generation: they are stored as one-way hashes, so the only way to see them is once, at (re)generation time.

func (*TwoFactor) Verify

func (s *TwoFactor) Verify(user *models.User, code string) (bool, error)

Verify checks a TOTP code against a confirmed user's secret WITHOUT replay protection. Used by the programmatic facade; the login challenge uses VerifyLoginCode instead, which is single-use.

func (*TwoFactor) VerifyLoginCode

func (s *TwoFactor) VerifyLoginCode(ctx context.Context, id uuid.UUID, code string) (bool, error)

VerifyLoginCode verifies a TOTP code for the login challenge and enforces single-use: the code's time-step must be newer than the last accepted one (rejecting replay within the validity window). The check + the last-used stamp are written atomically under a row lock.

type Users

type Users struct {
	// contains filtered or unexported fields
}

Users orchestrates admin user-management use cases and owns their validation.

func NewUsers

func NewUsers(repo repositories.UsersRepository, hasher Hasher, minPwLen int, roles, managementRoles []string) *Users

NewUsers builds the user-management service. minPwLen <= 0 falls back to DefaultMinPasswordLength. roles, when non-empty, restricts the accepted role values (Create/Update reject anything outside the set); empty means any role. managementRoles is the set of privileged roles (those that can manage users); it backs the "default new users to non-admin" choice and the "keep at least one active admin" invariants on delete/disable/demote.

func (*Users) Create

func (s *Users) Create(ctx context.Context, email, name, password, role string) (*models.User, error)

Create validates input and inserts a new user. Idempotent callers (e.g. a bootstrap command) should check for ErrAlreadyExists.

func (*Users) Delete

func (s *Users) Delete(ctx context.Context, id uuid.UUID) error

Delete removes a user, refusing to remove the last active admin. The guard counts users holding a management role (not total rows): deleting a management user is refused when no OTHER active management user remains.

func (*Users) GetByID

func (s *Users) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error)

GetByID returns a user or ErrNotFound.

func (*Users) List

func (s *Users) List(ctx context.Context) ([]models.User, error)

List returns all users ordered by creation time.

func (*Users) SetPassword

func (s *Users) SetPassword(ctx context.Context, id uuid.UUID, newPassword string) (*models.User, error)

SetPassword resets a user's password (admin action). It bumps password_changed_at, which invalidates that user's existing sessions.

func (*Users) Update

func (s *Users) Update(ctx context.Context, id uuid.UUID, email, name, role string, disabled *bool, actorID uuid.UUID) (*models.User, error)

Update changes a user's email, name, and role (not the password). disabled, when non-nil, locks (true) or unlocks (false) the account; nil leaves the lock state untouched. It enforces two invariants on a user who currently holds a management role: it cannot self-disable, and it cannot be disabled or demoted away from its management role if it is the last active admin (ErrLastAdmin).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL