Files
warmbly/internal/app/auth/registration.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

150 lines
4.0 KiB
Go

package auth
import (
"context"
"time"
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/notify/templates"
"github.com/warmbly/warmbly/internal/pkg/argon2"
"github.com/warmbly/warmbly/internal/pkg/crypt"
)
func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ipaddr string) (*models.AuthSession, *errx.Error) {
if s.policy.DisablePasswordLogin {
return nil, errx.New(errx.Forbidden, "password sign-up is disabled on this deployment")
}
if err := s.signupAllowed(ctx, data.Email, data.Invite); err != nil {
return nil, err
}
if xerr := s.captcha.Verify(ctx, data.Turnstile, ipaddr); xerr != nil {
sentry.CaptureException(xerr)
return nil, xerr
}
if !crypt.ValidatePassword(data.Password) {
return nil, errx.ErrPassword
}
passwordHash, xerr := argon2.Hash(data.Password)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
// With verification off, or with a transport that cannot deliver, there is
// nothing to confirm: create the account now rather than issuing a code
// nobody can receive. Every product surveyed defaults self-host to this.
if !s.policy.RequireEmailVerification || !s.mailDelivers {
if err := s.createAccount(ctx, data.Email, passwordHash, data.ReferralCode, data.Invite); err != nil {
return nil, err
}
return &models.AuthSession{CodeRequired: false}, nil
}
if err := s.canSendEmail(ctx, emailFlowRegistration, data.Email); err != nil {
return nil, err
}
issuedAt := time.Now()
expiresAt := issuedAt.Add(AuthSessionTTL)
sessionID := uuid.New()
nonce, xerr := crypt.Nonce()
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
code, xerr := crypt.VerificationCode()
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
text, xerr := templates.GenerateRegistrationCodeHTML(code)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
if xerr := s.sendAuthEmail(ctx, data.Email, "Your Verification Code", text); xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.ErrMailUndeliverable
}
codeHash, xerr := argon2.Hash(code)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
session := &models.RegistrationSession{
CodeHash: codeHash,
PasswordHash: passwordHash,
Nonce: nonce,
ReferralCode: data.ReferralCode,
Invite: data.Invite,
}
if err := s.saveRegistrationSession(ctx, sessionID, session, expiresAt); err != nil {
return nil, err
}
sessionToken, xerr := s.tokenService.GenerateToken(uuid.Nil, sessionID, data.Email, nonce, issuedAt, expiresAt)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
return &models.AuthSession{
Session: sessionToken,
CodeRequired: true,
}, nil
}
func (s *authService) RegistrationConfirm(ctx context.Context, data *ConfirmData, session, ipaddr string) *errx.Error {
token, err := s.tokenService.VerifyToken(session)
if err != nil {
return err
}
if token.ExpiresAt.Before(time.Now()) {
return errx.ErrSession
}
sess, err := s.getRegistrationSession(ctx, token.SessionID)
if err != nil {
return err
}
if sess == nil || sess.Nonce != token.Nonce {
return errx.ErrSession
}
if sess.Tries >= AuthAttempts {
return errx.ErrCodeLimit
}
v, xerr := argon2.Verify(data.Code, sess.CodeHash)
if xerr != nil {
sentry.CaptureException(xerr)
return errx.InternalError()
}
if !v {
sess.Tries++
_ = s.saveRegistrationSession(ctx, token.SessionID, sess, token.ExpiresAt.Time)
return errx.ErrCode
}
// Re-check the policy: a session minted while signups were open must not
// outlive a lockdown applied before the code came back.
if err := s.signupAllowed(ctx, token.Email, sess.Invite); err != nil {
return err
}
return s.createAccount(ctx, token.Email, sess.PasswordHash, sess.ReferralCode, sess.Invite)
}