Files
warmbly/internal/app/auth/service.go
T
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

183 lines
8.0 KiB
Go

package auth
import (
"context"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/token"
"github.com/warmbly/warmbly/internal/app/trial"
"github.com/warmbly/warmbly/internal/app/user"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/cache"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/notify"
"github.com/warmbly/warmbly/internal/pkg/captcha"
"github.com/warmbly/warmbly/internal/repository"
)
// TwoFAChallenger issues + checks the 2FA login challenge after the email-code
// step. Satisfied by *twofa.Service; injected post-construction (WireTwoFA) so
// the auth package needs no import of twofa (no cycle).
type TwoFAChallenger interface {
IsEnabled(ctx context.Context, userID uuid.UUID) (bool, error)
CreatePendingChallenge(ctx context.Context, userID uuid.UUID) (string, int, *errx.Error)
}
// ReferralAttributor links a brand-new org to the referrer behind its signup
// code. Satisfied by *referral.Service; injected post-construction (WireReferral)
// so the auth package needs no import of referral (no cycle).
type ReferralAttributor interface {
AttributeSignup(ctx context.Context, code string, inviteeOrgID, inviteeUserID uuid.UUID) *errx.Error
}
// InstanceSettings is the operator-editable half of the signup policy.
// Satisfied by instancesettings.Service; injected post-construction
// (WireInstanceSettings) so the auth package needs no import of it (no cycle).
type InstanceSettings interface {
AllowInvitedSignup(ctx context.Context) bool
}
type AuthService interface {
LoginStart(ctx context.Context, data *AuthData, ipaddr, userAgent string) (*models.AuthSession, *errx.Error)
LoginConfirm(ctx context.Context, data *ConfirmData, session, ipaddr, userAgent string) (*models.LoginResult, *errx.Error)
// WireTwoFA attaches the 2FA challenger (post-construction; nil = 2FA off).
WireTwoFA(t TwoFAChallenger)
RegistrationStart(ctx context.Context, data *AuthData, ipaddr string) (*models.AuthSession, *errx.Error)
RegistrationConfirm(ctx context.Context, data *ConfirmData, session, ipaddr string) *errx.Error
// WireReferral attaches the referral attributor (post-construction; nil = no
// referral attribution at signup).
WireReferral(r ReferralAttributor)
// WireInstanceSettings attaches the database-backed instance settings
// (post-construction; nil keeps the permissive defaults).
WireInstanceSettings(s InstanceSettings)
// Native-app social sign-in: the client authenticates with the provider on
// device and exchanges the resulting ID token for a session. First sign-in
// provisions the account (org + trial) like password registration.
AppleIDTokenAuth(ctx context.Context, rawToken, firstName, lastName, ipaddr, userAgent string) (*models.LoginResult, *errx.Error)
GoogleIDTokenAuth(ctx context.Context, rawToken, ipaddr, userAgent string) (*models.LoginResult, *errx.Error)
// WireExternalIDTokens attaches the ID-token verifiers (post-construction;
// a nil verifier disables that provider).
WireExternalIDTokens(apple, google IDTokenVerifier)
ResetPasswordStart(ctx context.Context, data *ResetPasswordStart, ipaddr string) *errx.Error
ResetPasswordConfirm(ctx context.Context, data *ResetPasswordConfirm, session, ipaddr string) *errx.Error
// ChangePassword updates a logged-in user's password after verifying the
// current one.
ChangePassword(ctx context.Context, userID, currentSessionID uuid.UUID, data *ChangePassword) *errx.Error
// Policy is the resolved per-deployment auth behavior, exposed so the
// public /auth/config endpoint can report it to the login screen.
Policy() *config.AuthPolicy
// RegistrationMode is Policy().Registration with the first-launch
// exemption applied, so a brand new instance advertises open signups.
RegistrationMode(ctx context.Context) string
// WireDeployment attaches the deployment-level facts auth decisions depend
// on: the resolved policy and whether the mail transport actually delivers.
WireDeployment(policy *config.AuthPolicy, mailDelivers bool)
// WireIdentities attaches the federated-identity store, so external
// sign-in resolves accounts by (issuer, subject) instead of email.
WireIdentities(r repository.IdentityRepository)
// Generic OIDC. WireOIDC attaches the provider (nil = OIDC disabled).
WireOIDC(p OIDCProvider)
OIDCBegin(ctx context.Context) (*OIDCRedirect, *errx.Error)
// OIDCCallback returns a single-use handoff code, not a session: the
// provider redirects a browser here, so the response must be a redirect.
OIDCCallback(ctx context.Context, code, state, ipaddr, userAgent string) (string, *errx.Error)
OIDCExchange(ctx context.Context, code string) (*models.LoginResult, *errx.Error)
}
type authService struct {
authRepository repository.AuthRepository
userRepository repository.UserRepository
tokenService token.TokenService
userService user.UserService
trialService trial.TrialService
organizationService organization.OrganizationService
emailNotificationService notify.EmailNotificationService
cache *cache.Cache
captcha *captcha.Turnstile
externalAuth *models.ExternalAuth
appleIDTokens IDTokenVerifier
googleIDTokens IDTokenVerifier
twofa TwoFAChallenger
referral ReferralAttributor
// settings is the operator-editable settings document, wired after
// construction because it needs the database pool.
settings InstanceSettings
// identities binds federated logins to (issuer, subject). Nil-safe: an
// unwired repository falls back to the historic email-only matching, which
// is only safe for Apple and Google.
identities repository.IdentityRepository
// oidc is the generic OpenID Connect provider, nil unless configured.
oidc OIDCProvider
// policy and mailDelivers govern whether an emailed code gates a login and
// whether public signups are open. Defaults are set in NewService so a
// caller that never wires them still behaves like the historic flow.
policy *config.AuthPolicy
mailDelivers bool
}
func (s *authService) WireTwoFA(t TwoFAChallenger) { s.twofa = t }
func (s *authService) WireDeployment(policy *config.AuthPolicy, mailDelivers bool) {
if policy != nil {
s.policy = policy
}
s.mailDelivers = mailDelivers
}
func (s *authService) Policy() *config.AuthPolicy { return s.policy }
func (s *authService) WireIdentities(r repository.IdentityRepository) { s.identities = r }
func (s *authService) WireOIDC(p OIDCProvider) { s.oidc = p }
func (s *authService) WireReferral(r ReferralAttributor) { s.referral = r }
// WireInstanceSettings attaches the instance settings document, so the signup
// knobs on the admin page reach the registration paths.
func (s *authService) WireInstanceSettings(set InstanceSettings) { s.settings = set }
func NewService(
authRepository repository.AuthRepository,
cache *cache.Cache,
captcha *captcha.Turnstile,
tokenService token.TokenService,
emailNotificationService notify.EmailNotificationService,
externalAuthData *models.ExternalAuth,
trialService trial.TrialService,
organizationService organization.OrganizationService,
userRepository repository.UserRepository,
userService user.UserService,
) AuthService {
return &authService{
authRepository: authRepository,
tokenService: tokenService,
emailNotificationService: emailNotificationService,
cache: cache,
captcha: captcha,
externalAuth: externalAuthData,
trialService: trialService,
organizationService: organizationService,
userRepository: userRepository,
userService: userService,
// Overwritten by WireDeployment during boot. The default assumes a
// delivering transport so an unwired service keeps the historic
// always-email-a-code behavior rather than silently relaxing it.
policy: config.LoadAuthPolicy(true),
mailDelivers: true,
}
}