package storage import ( "context" "crypto/subtle" "database/sql" "encoding/json" "errors" "fmt" "time" ) var ( ErrInvalidToken = errors.New("storage: invitation token is not valid") ErrBootstrapFirst = errors.New("storage: no user exists yet, so only the bootstrap invitation is redeemable") ErrUsernameTaken = errors.New("storage: username is taken") ) type Grant int const ( GrantOrdinary Grant = iota + 1 GrantBootstrap ) type Redemption struct { Grant Grant } type RedeemInput struct { TokenHash []byte User CreateUserInput DefaultGroups []string AdminGroup string } type Redeemed struct { User User Grant Grant Groups []string Dropped []string Code string } type redemptionTarget struct { grant Grant invitationID string validFor time.Duration } func (s *Store) CheckRedemption(ctx context.Context, tokenHash []byte) (Redemption, error) { const operation = "check redemption" if err := s.checkOpen(operation); err != nil { return Redemption{}, err } target, err := findRedemption(ctx, s.sql, operation, tokenHash, canonicalTime(s.now())) if err != nil { return Redemption{}, err } return Redemption{Grant: target.grant}, nil } func (s *Store) RedeemInvitation(ctx context.Context, input RedeemInput) (Redeemed, error) { const operation = "redeem invitation" if err := s.checkOpen(operation); err != nil { return Redeemed{}, err } user, err := s.prepareUser(operation, input.User) if err != nil { return Redeemed{}, err } tx, err := s.sql.BeginTx(ctx, nil) if err != nil { return Redeemed{}, classify("begin "+operation, err) } defer tx.Rollback() target, err := findRedemption(ctx, tx, operation, input.TokenHash, user.CreatedAt) if err != nil { return Redeemed{}, err } groups := append([]string(nil), input.DefaultGroups...) if target.grant == GrantBootstrap { groups = appendUnique(groups, input.AdminGroup) } if err := requireFree(ctx, tx, operation, `SELECT 1 FROM users WHERE username = ?`, user.Username, ErrUsernameTaken); err != nil { return Redeemed{}, err } user.Disabled = target.grant == GrantOrdinary user, err = insertUser(ctx, tx, operation, user) if err != nil { return Redeemed{}, err } var code string if target.grant == GrantOrdinary { if code, err = insertPending(ctx, tx, operation, user.ID, user.CreatedAt, user.CreatedAt.Add(target.validFor)); err != nil { return Redeemed{}, err } } for _, name := range groups { group, _, err := s.ensureGroup(ctx, tx, operation, name, "system") if err != nil { return Redeemed{}, err } if err := addMembership(ctx, tx, operation, user.ID, group.ID); err != nil { return Redeemed{}, err } } var dropped []string if target.grant == GrantOrdinary { if groups, dropped, err = grantInvitationGroups(ctx, tx, operation, target.invitationID, user.ID, input.AdminGroup, groups); err != nil { return Redeemed{}, err } } action, subject := "bootstrap.redeem", "bootstrap" if target.grant == GrantOrdinary { action, subject = "invitation.redeem", target.invitationID result, err := tx.ExecContext(ctx, `UPDATE invitations SET used_by = ?, used_at = ? WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL`, user.ID, timeToInt(user.CreatedAt), target.invitationID) if err != nil { return Redeemed{}, classify(operation, err) } if changed, err := result.RowsAffected(); err != nil { return Redeemed{}, classify(operation+" rows affected", err) } else if changed != 1 { return Redeemed{}, fmt.Errorf("%s: %w", operation, ErrInvalidToken) } } detail, err := json.Marshal(struct { Username string `json:"username"` Groups []string `json:"groups"` Dropped []string `json:"dropped,omitempty"` Pending bool `json:"pending,omitempty"` }{user.Username, groups, dropped, target.grant == GrantOrdinary}) if err != nil { return Redeemed{}, fmt.Errorf("%s: %w", operation, err) } if _, err := appendAudit(ctx, tx, operation, AppendAuditInput{Actor: user.ID, Action: action, Subject: subject, Detail: string(detail)}, user.CreatedAt); err != nil { return Redeemed{}, err } if err := tx.Commit(); err != nil { return Redeemed{}, classify("commit "+operation, err) } return Redeemed{User: user, Grant: target.grant, Groups: groups, Dropped: dropped, Code: code}, nil } func findRedemption(ctx context.Context, q execer, operation string, tokenHash []byte, now time.Time) (redemptionTarget, error) { invalidToken := fmt.Errorf("%s: %w", operation, ErrInvalidToken) if len(tokenHash) != bootstrapHashLength { return redemptionTarget{}, invalidToken } var id string var invCreated, invExpires int64 err := q.QueryRowContext(ctx, ` SELECT id, created_at, expires_at FROM invitations WHERE token_hash = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?`, tokenHash, timeToInt(now)).Scan(&id, &invCreated, &invExpires) if err == nil { users, err := countUsers(ctx, q, operation) if err != nil { return redemptionTarget{}, err } if users == 0 { return redemptionTarget{}, fmt.Errorf("%s: %w: %w", operation, ErrInvalidToken, ErrBootstrapFirst) } return redemptionTarget{grant: GrantOrdinary, invitationID: id, validFor: intToTime(invExpires).Sub(intToTime(invCreated))}, nil } if !errors.Is(err, sql.ErrNoRows) { return redemptionTarget{}, classify(operation, err) } var stored []byte var expires sql.NullInt64 if err := q.QueryRowContext(ctx, `SELECT bootstrap_token_hash, bootstrap_expires_at FROM settings WHERE id = 1`).Scan(&stored, &expires); err != nil { return redemptionTarget{}, classify(operation, err) } if len(stored) != bootstrapHashLength || !expires.Valid || subtle.ConstantTimeCompare(stored, tokenHash) != 1 { return redemptionTarget{}, invalidToken } if !intToTime(expires.Int64).After(now) { return redemptionTarget{}, invalidToken } users, err := countUsers(ctx, q, operation) if err != nil { return redemptionTarget{}, err } if users != 0 { return redemptionTarget{}, invalidToken } return redemptionTarget{grant: GrantBootstrap}, nil } func requireFree(ctx context.Context, tx *sql.Tx, operation, query, value string, taken error) error { var one int err := tx.QueryRowContext(ctx, query, value).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return nil } if err != nil { return classify(operation, err) } return fmt.Errorf("%s: %w", operation, taken) } func appendUnique(names []string, name string) []string { for _, existing := range names { if existing == name { return names } } return append(names, name) }