@nakedev/go-scaffold 0.4.0 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +42 -1
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +42 -14
- package/templates/create/base/cmd/api/wiring.go.hbs +11 -8
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +35 -13
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +2 -2
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"log/slog"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/model"
|
|
9
|
+
"{{goModule}}/internal/shared/apperror"
|
|
10
|
+
"{{goModule}}/internal/shared/dberr"
|
|
11
|
+
"{{goModule}}/internal/shared/id"
|
|
12
|
+
|
|
13
|
+
"golang.org/x/crypto/bcrypt"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// Failed-attempt policy. These values are security posture rather than
|
|
17
|
+
// per-environment tuning knobs. The first few failures cost nothing, then the
|
|
18
|
+
// wait doubles up to a bounded maximum.
|
|
19
|
+
const (
|
|
20
|
+
loginFreeAttempts = 3
|
|
21
|
+
loginMaxLock = 15 * time.Minute
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// throttleKey namespaces the counter by purpose, so a locked login never
|
|
25
|
+
// blocks the password reset that would fix it. The address is hashed because
|
|
26
|
+
// the counter must also work for addresses that have no account.
|
|
27
|
+
func throttleKey(purpose, email string) string {
|
|
28
|
+
return hashToken(purpose + ":" + normalizeEmail(email))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// throttled treats a counter read failure as not-throttled: this is a brake,
|
|
32
|
+
// and it should not be able to lock everybody out on its own.
|
|
33
|
+
func (s *Service) throttled(ctx context.Context, key string) bool {
|
|
34
|
+
until, err := s.repo.LoginLockedUntil(ctx, key)
|
|
35
|
+
if err != nil {
|
|
36
|
+
slog.Error("read login throttle", "error", err)
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
return !until.IsZero() && time.Now().Before(until)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func (s *Service) Register(ctx context.Context, in registerInput) (*authResult, error) {
|
|
43
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
|
44
|
+
if err != nil {
|
|
45
|
+
return nil, apperror.NewInternal(err)
|
|
46
|
+
}
|
|
47
|
+
hashStr := string(hash)
|
|
48
|
+
|
|
49
|
+
u := &model.User{ID: id.New(), Email: normalizeEmail(in.Email), Name: in.Name}
|
|
50
|
+
i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
|
|
51
|
+
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
52
|
+
if dberr.IsDuplicate(err) {
|
|
53
|
+
return nil, errEmailTaken()
|
|
54
|
+
}
|
|
55
|
+
return nil, apperror.NewInternal(err)
|
|
56
|
+
}
|
|
57
|
+
// Best-effort: a mail failure should not block registration. The user can
|
|
58
|
+
// request another link through ResendVerificationEmail.
|
|
59
|
+
s.sendVerificationEmail(ctx, u)
|
|
60
|
+
auth, err := s.issueTokens(ctx, u)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return nil, err
|
|
63
|
+
}
|
|
64
|
+
return tokenResult(auth), nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func (s *Service) Login(ctx context.Context, in loginInput) (*authResult, error) {
|
|
68
|
+
key := throttleKey("login", in.Email)
|
|
69
|
+
if s.throttled(ctx, key) {
|
|
70
|
+
return nil, errTooManyAttempts()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Every failure records the same key and returns the same error whether or
|
|
74
|
+
// not the account exists, preventing account enumeration through the counter.
|
|
75
|
+
fail := func() (*authResult, error) {
|
|
76
|
+
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
77
|
+
slog.Error("record login failure", "error", err)
|
|
78
|
+
}
|
|
79
|
+
return nil, errInvalidCredentials()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
u, err := s.repo.FindByEmail(ctx, normalizeEmail(in.Email))
|
|
83
|
+
if err != nil {
|
|
84
|
+
return fail()
|
|
85
|
+
}
|
|
86
|
+
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
87
|
+
if err != nil || ident.PasswordHash == nil {
|
|
88
|
+
return fail()
|
|
89
|
+
}
|
|
90
|
+
if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
|
|
91
|
+
return fail()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
|
|
95
|
+
slog.Error("clear login failures", "error", err)
|
|
96
|
+
}
|
|
97
|
+
return s.completeLogin(ctx, u)
|
|
98
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/aes"
|
|
6
|
+
"crypto/cipher"
|
|
7
|
+
"crypto/hmac"
|
|
8
|
+
"crypto/rand"
|
|
9
|
+
"crypto/sha1"
|
|
10
|
+
"crypto/subtle"
|
|
11
|
+
"encoding/base32"
|
|
12
|
+
"encoding/base64"
|
|
13
|
+
"encoding/binary"
|
|
14
|
+
"encoding/hex"
|
|
15
|
+
"errors"
|
|
16
|
+
"fmt"
|
|
17
|
+
"net/url"
|
|
18
|
+
"strings"
|
|
19
|
+
"time"
|
|
20
|
+
|
|
21
|
+
"{{goModule}}/internal/app/user/model"
|
|
22
|
+
"{{goModule}}/internal/shared/apperror"
|
|
23
|
+
|
|
24
|
+
"github.com/google/uuid"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const (
|
|
28
|
+
totpDigits = 6
|
|
29
|
+
totpPeriod = 30 * time.Second
|
|
30
|
+
defaultMFAWindow = 1
|
|
31
|
+
maxMFAWindow = 3
|
|
32
|
+
defaultMFATTL = 5 * time.Minute
|
|
33
|
+
defaultMFACodes = 10
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// MFASetup contains the secret material an authenticator app needs. It is
|
|
37
|
+
// returned only while setup is pending; status and login never expose it.
|
|
38
|
+
type MFASetup struct {
|
|
39
|
+
Secret string
|
|
40
|
+
OTPAuthURI string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type MFAStatus struct {
|
|
44
|
+
Available bool
|
|
45
|
+
Enabled bool
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (s *Service) mfaSettings() MFASettings {
|
|
49
|
+
settings := s.config.MFA
|
|
50
|
+
if strings.TrimSpace(settings.Issuer) == "" {
|
|
51
|
+
settings.Issuer = "go-scaffold"
|
|
52
|
+
}
|
|
53
|
+
if settings.ChallengeTTL <= 0 {
|
|
54
|
+
settings.ChallengeTTL = defaultMFATTL
|
|
55
|
+
}
|
|
56
|
+
if settings.TOTPWindow < 0 || settings.TOTPWindow > maxMFAWindow {
|
|
57
|
+
settings.TOTPWindow = defaultMFAWindow
|
|
58
|
+
}
|
|
59
|
+
if settings.RecoveryCodeCount <= 0 {
|
|
60
|
+
settings.RecoveryCodeCount = defaultMFACodes
|
|
61
|
+
}
|
|
62
|
+
if settings.RecoveryCodeCount > 20 {
|
|
63
|
+
settings.RecoveryCodeCount = 20
|
|
64
|
+
}
|
|
65
|
+
return settings
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ValidateMFASettings is called by the composition root when the operator
|
|
69
|
+
// enables MFA, so a bad encryption key fails at boot rather than after the
|
|
70
|
+
// first user clicks "enable MFA".
|
|
71
|
+
func ValidateMFASettings(settings MFASettings) error {
|
|
72
|
+
if !settings.Enabled {
|
|
73
|
+
return nil
|
|
74
|
+
}
|
|
75
|
+
if _, err := decodeMFAKey(settings.EncryptionKey); err != nil {
|
|
76
|
+
return fmt.Errorf("MFA_ENCRYPTION_KEY must be base64-encoded 32 bytes: %w", err)
|
|
77
|
+
}
|
|
78
|
+
if settings.ChallengeTTL <= 0 {
|
|
79
|
+
return errors.New("MFA_CHALLENGE_TTL must be positive")
|
|
80
|
+
}
|
|
81
|
+
if settings.TOTPWindow < 0 || settings.TOTPWindow > maxMFAWindow {
|
|
82
|
+
return fmt.Errorf("MFA_TOTP_WINDOW must be between 0 and %d", maxMFAWindow)
|
|
83
|
+
}
|
|
84
|
+
if settings.RecoveryCodeCount < 5 || settings.RecoveryCodeCount > 20 {
|
|
85
|
+
return errors.New("MFA_RECOVERY_CODE_COUNT must be between 5 and 20")
|
|
86
|
+
}
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func (s *Service) completeLogin(ctx context.Context, u *model.User) (*authResult, error) {
|
|
91
|
+
settings := s.mfaSettings()
|
|
92
|
+
if !settings.Enabled {
|
|
93
|
+
auth, err := s.issueTokens(ctx, u)
|
|
94
|
+
if err != nil {
|
|
95
|
+
return nil, err
|
|
96
|
+
}
|
|
97
|
+
return tokenResult(auth), nil
|
|
98
|
+
}
|
|
99
|
+
if s.mfa == nil {
|
|
100
|
+
return nil, apperror.NewInternal(errors.New("MFA is enabled but no MFA store was configured"))
|
|
101
|
+
}
|
|
102
|
+
enrollment, found, err := s.mfa.GetEnrollment(ctx, u.ID)
|
|
103
|
+
if err != nil {
|
|
104
|
+
return nil, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
|
|
105
|
+
}
|
|
106
|
+
if !found || !enrollment.Enabled {
|
|
107
|
+
auth, err := s.issueTokens(ctx, u)
|
|
108
|
+
if err != nil {
|
|
109
|
+
return nil, err
|
|
110
|
+
}
|
|
111
|
+
return tokenResult(auth), nil
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
rawChallenge, err := randomToken()
|
|
115
|
+
if err != nil {
|
|
116
|
+
return nil, apperror.NewInternal(fmt.Errorf("create MFA challenge: %w", err))
|
|
117
|
+
}
|
|
118
|
+
if err := s.mfa.CreateChallenge(ctx, hashToken(rawChallenge), MFAChallenge{
|
|
119
|
+
UserID: u.ID,
|
|
120
|
+
ExpiresAt: s.clock().Add(settings.ChallengeTTL),
|
|
121
|
+
}); err != nil {
|
|
122
|
+
return nil, apperror.NewInternal(fmt.Errorf("store MFA challenge: %w", err))
|
|
123
|
+
}
|
|
124
|
+
return challengeResult(rawChallenge), nil
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func (s *Service) MFAStatus(ctx context.Context, userID uuid.UUID) (MFAStatus, error) {
|
|
128
|
+
settings := s.mfaSettings()
|
|
129
|
+
if !settings.Enabled || s.mfa == nil {
|
|
130
|
+
return MFAStatus{Available: false}, nil
|
|
131
|
+
}
|
|
132
|
+
enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
|
|
133
|
+
if err != nil {
|
|
134
|
+
return MFAStatus{}, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
|
|
135
|
+
}
|
|
136
|
+
return MFAStatus{Available: true, Enabled: found && enrollment.Enabled}, nil
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func (s *Service) SetupMFA(ctx context.Context, userID uuid.UUID) (*MFASetup, error) {
|
|
140
|
+
settings, err := s.requireMFA()
|
|
141
|
+
if err != nil {
|
|
142
|
+
return nil, err
|
|
143
|
+
}
|
|
144
|
+
if s.mfa == nil {
|
|
145
|
+
return nil, apperror.NewInternal(errors.New("MFA store is not configured"))
|
|
146
|
+
}
|
|
147
|
+
enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
|
|
148
|
+
if err != nil {
|
|
149
|
+
return nil, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
|
|
150
|
+
}
|
|
151
|
+
if found && enrollment.Enabled {
|
|
152
|
+
return nil, errMFAAlreadyEnabled()
|
|
153
|
+
}
|
|
154
|
+
u, err := s.repo.FindByID(ctx, userID)
|
|
155
|
+
if err != nil {
|
|
156
|
+
return nil, wrapFindErr(err)
|
|
157
|
+
}
|
|
158
|
+
secret, err := newTOTPSecret()
|
|
159
|
+
if err != nil {
|
|
160
|
+
return nil, apperror.NewInternal(fmt.Errorf("generate MFA secret: %w", err))
|
|
161
|
+
}
|
|
162
|
+
encrypted, err := encryptMFASecret(settings.EncryptionKey, secret)
|
|
163
|
+
if err != nil {
|
|
164
|
+
return nil, errMFAConfig()
|
|
165
|
+
}
|
|
166
|
+
if err := s.mfa.PutPendingEnrollment(ctx, userID, encrypted); err != nil {
|
|
167
|
+
return nil, apperror.NewInternal(fmt.Errorf("store MFA enrollment: %w", err))
|
|
168
|
+
}
|
|
169
|
+
return &MFASetup{
|
|
170
|
+
Secret: secret,
|
|
171
|
+
OTPAuthURI: buildOTPAuthURI(settings.Issuer, u.Email, secret),
|
|
172
|
+
}, nil
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func (s *Service) ConfirmMFA(ctx context.Context, userID uuid.UUID, code string) ([]string, error) {
|
|
176
|
+
settings, err := s.requireMFA()
|
|
177
|
+
if err != nil {
|
|
178
|
+
return nil, err
|
|
179
|
+
}
|
|
180
|
+
if s.mfa == nil {
|
|
181
|
+
return nil, apperror.NewInternal(errors.New("MFA store is not configured"))
|
|
182
|
+
}
|
|
183
|
+
enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
|
|
184
|
+
if err != nil {
|
|
185
|
+
return nil, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
|
|
186
|
+
}
|
|
187
|
+
if !found {
|
|
188
|
+
return nil, errMFASetupRequired()
|
|
189
|
+
}
|
|
190
|
+
if enrollment.Enabled {
|
|
191
|
+
return nil, errMFAAlreadyEnabled()
|
|
192
|
+
}
|
|
193
|
+
secret, err := decryptMFASecret(settings.EncryptionKey, enrollment.EncryptedSecret)
|
|
194
|
+
if err != nil {
|
|
195
|
+
return nil, errMFAConfig()
|
|
196
|
+
}
|
|
197
|
+
if !validTOTPCode(secret, code, s.clock(), settings.TOTPWindow) {
|
|
198
|
+
return nil, errMFAInvalid()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
codes, hashes, err := newRecoveryCodes(settings.RecoveryCodeCount)
|
|
202
|
+
if err != nil {
|
|
203
|
+
return nil, apperror.NewInternal(fmt.Errorf("generate MFA recovery codes: %w", err))
|
|
204
|
+
}
|
|
205
|
+
if err := s.mfa.ConfirmEnrollment(ctx, userID, enrollment.EncryptedSecret, hashes); err != nil {
|
|
206
|
+
return nil, apperror.NewInternal(fmt.Errorf("confirm MFA enrollment: %w", err))
|
|
207
|
+
}
|
|
208
|
+
return codes, nil
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// DisableMFA requires a current TOTP code. Recovery codes are intentionally
|
|
212
|
+
// accepted for login only: consuming one and then failing the disable write
|
|
213
|
+
// would otherwise destroy a recovery factor without completing the change.
|
|
214
|
+
func (s *Service) DisableMFA(ctx context.Context, userID uuid.UUID, code string) error {
|
|
215
|
+
settings, err := s.requireMFA()
|
|
216
|
+
if err != nil {
|
|
217
|
+
return err
|
|
218
|
+
}
|
|
219
|
+
if s.mfa == nil {
|
|
220
|
+
return apperror.NewInternal(errors.New("MFA store is not configured"))
|
|
221
|
+
}
|
|
222
|
+
enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
|
|
223
|
+
if err != nil {
|
|
224
|
+
return apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
|
|
225
|
+
}
|
|
226
|
+
if !found || !enrollment.Enabled {
|
|
227
|
+
return errMFANotEnrolled()
|
|
228
|
+
}
|
|
229
|
+
secret, err := decryptMFASecret(settings.EncryptionKey, enrollment.EncryptedSecret)
|
|
230
|
+
if err != nil {
|
|
231
|
+
return errMFAConfig()
|
|
232
|
+
}
|
|
233
|
+
if !validTOTPCode(secret, code, s.clock(), settings.TOTPWindow) {
|
|
234
|
+
return errMFAInvalid()
|
|
235
|
+
}
|
|
236
|
+
if err := s.mfa.Disable(ctx, userID); err != nil {
|
|
237
|
+
return apperror.NewInternal(fmt.Errorf("disable MFA: %w", err))
|
|
238
|
+
}
|
|
239
|
+
return nil
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// VerifyMFA consumes the challenge before checking the code. This makes every
|
|
243
|
+
// challenge single-use, including a failed attempt, and prevents an attacker
|
|
244
|
+
// from turning a leaked challenge into an unlimited six-digit guessing oracle.
|
|
245
|
+
func (s *Service) VerifyMFA(ctx context.Context, rawChallenge, code string) (*authResponse, error) {
|
|
246
|
+
settings, err := s.requireMFA()
|
|
247
|
+
if err != nil {
|
|
248
|
+
return nil, err
|
|
249
|
+
}
|
|
250
|
+
if s.mfa == nil {
|
|
251
|
+
return nil, apperror.NewInternal(errors.New("MFA store is not configured"))
|
|
252
|
+
}
|
|
253
|
+
challenge, found, err := s.mfa.ConsumeChallenge(ctx, hashToken(rawChallenge))
|
|
254
|
+
if err != nil {
|
|
255
|
+
return nil, apperror.NewInternal(fmt.Errorf("consume MFA challenge: %w", err))
|
|
256
|
+
}
|
|
257
|
+
if !found {
|
|
258
|
+
return nil, errMFAInvalid()
|
|
259
|
+
}
|
|
260
|
+
enrollment, found, err := s.mfa.GetEnrollment(ctx, challenge.UserID)
|
|
261
|
+
if err != nil {
|
|
262
|
+
return nil, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
|
|
263
|
+
}
|
|
264
|
+
if !found || !enrollment.Enabled {
|
|
265
|
+
return nil, errMFAInvalid()
|
|
266
|
+
}
|
|
267
|
+
secret, err := decryptMFASecret(settings.EncryptionKey, enrollment.EncryptedSecret)
|
|
268
|
+
if err != nil {
|
|
269
|
+
return nil, errMFAConfig()
|
|
270
|
+
}
|
|
271
|
+
if !validTOTPCode(secret, code, s.clock(), settings.TOTPWindow) {
|
|
272
|
+
recoveryHash, ok := normalizedRecoveryCodeHash(code)
|
|
273
|
+
if !ok {
|
|
274
|
+
return nil, errMFAInvalid()
|
|
275
|
+
}
|
|
276
|
+
used, err := s.mfa.ConsumeRecoveryCode(ctx, challenge.UserID, recoveryHash)
|
|
277
|
+
if err != nil {
|
|
278
|
+
return nil, apperror.NewInternal(fmt.Errorf("consume MFA recovery code: %w", err))
|
|
279
|
+
}
|
|
280
|
+
if !used {
|
|
281
|
+
return nil, errMFAInvalid()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
u, err := s.repo.FindByID(ctx, challenge.UserID)
|
|
286
|
+
if err != nil {
|
|
287
|
+
return nil, errInvalidCredentials()
|
|
288
|
+
}
|
|
289
|
+
return s.issueTokens(ctx, u)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func (s *Service) requireMFA() (MFASettings, error) {
|
|
293
|
+
settings := s.mfaSettings()
|
|
294
|
+
if !settings.Enabled {
|
|
295
|
+
return MFASettings{}, errMFAUnavailable()
|
|
296
|
+
}
|
|
297
|
+
return settings, nil
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
func newTOTPSecret() (string, error) {
|
|
301
|
+
bytes := make([]byte, 20)
|
|
302
|
+
if _, err := rand.Read(bytes); err != nil {
|
|
303
|
+
return "", err
|
|
304
|
+
}
|
|
305
|
+
return strings.TrimRight(base32.StdEncoding.EncodeToString(bytes), "="), nil
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func buildOTPAuthURI(issuer, email, secret string) string {
|
|
309
|
+
label := url.PathEscape(issuer + ":" + email)
|
|
310
|
+
query := url.Values{
|
|
311
|
+
"secret": {secret},
|
|
312
|
+
"issuer": {issuer},
|
|
313
|
+
"algorithm": {"SHA1"},
|
|
314
|
+
"digits": {fmt.Sprintf("%d", totpDigits)},
|
|
315
|
+
"period": {fmt.Sprintf("%d", int(totpPeriod/time.Second))},
|
|
316
|
+
}
|
|
317
|
+
return "otpauth://totp/" + label + "?" + query.Encode()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func validTOTPCode(secret, rawCode string, now time.Time, window int) bool {
|
|
321
|
+
code := strings.TrimSpace(rawCode)
|
|
322
|
+
if len(code) != totpDigits {
|
|
323
|
+
return false
|
|
324
|
+
}
|
|
325
|
+
for i := 0; i < len(code); i++ {
|
|
326
|
+
if code[i] < '0' || code[i] > '9' {
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for offset := -window; offset <= window; offset++ {
|
|
331
|
+
candidate, err := totpCode(secret, now.Add(time.Duration(offset)*totpPeriod))
|
|
332
|
+
if err == nil && subtle.ConstantTimeCompare([]byte(candidate), []byte(code)) == 1 {
|
|
333
|
+
return true
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// totpCode follows RFC 6238's interoperable default: HMAC-SHA-1, six digits,
|
|
340
|
+
// and a 30-second time step, which is what common authenticator apps expect.
|
|
341
|
+
func totpCode(secret string, now time.Time) (string, error) {
|
|
342
|
+
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(strings.TrimSpace(secret)))
|
|
343
|
+
if err != nil || len(decoded) == 0 {
|
|
344
|
+
return "", errors.New("invalid base32 TOTP secret")
|
|
345
|
+
}
|
|
346
|
+
counter := uint64(now.Unix() / int64(totpPeriod/time.Second))
|
|
347
|
+
var message [8]byte
|
|
348
|
+
binary.BigEndian.PutUint64(message[:], counter)
|
|
349
|
+
hash := hmac.New(sha1.New, decoded)
|
|
350
|
+
_, _ = hash.Write(message[:])
|
|
351
|
+
sum := hash.Sum(nil)
|
|
352
|
+
offset := sum[len(sum)-1] & 0x0f
|
|
353
|
+
value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
|
|
354
|
+
return fmt.Sprintf("%06d", value%1000000), nil
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
func newRecoveryCodes(count int) ([]string, []string, error) {
|
|
358
|
+
codes := make([]string, 0, count)
|
|
359
|
+
hashes := make([]string, 0, count)
|
|
360
|
+
for i := 0; i < count; i++ {
|
|
361
|
+
bytes := make([]byte, 8)
|
|
362
|
+
if _, err := rand.Read(bytes); err != nil {
|
|
363
|
+
return nil, nil, err
|
|
364
|
+
}
|
|
365
|
+
normalized := strings.ToUpper(hex.EncodeToString(bytes))
|
|
366
|
+
code := normalized[:4] + "-" + normalized[4:8] + "-" + normalized[8:12] + "-" + normalized[12:]
|
|
367
|
+
hash, ok := normalizedRecoveryCodeHash(code)
|
|
368
|
+
if !ok {
|
|
369
|
+
return nil, nil, errors.New("generated invalid MFA recovery code")
|
|
370
|
+
}
|
|
371
|
+
codes = append(codes, code)
|
|
372
|
+
hashes = append(hashes, hash)
|
|
373
|
+
}
|
|
374
|
+
return codes, hashes, nil
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
func normalizedRecoveryCodeHash(raw string) (string, bool) {
|
|
378
|
+
value := strings.ToUpper(strings.TrimSpace(raw))
|
|
379
|
+
value = strings.NewReplacer("-", "", " ", "").Replace(value)
|
|
380
|
+
if len(value) != 16 {
|
|
381
|
+
return "", false
|
|
382
|
+
}
|
|
383
|
+
for i := 0; i < len(value); i++ {
|
|
384
|
+
if (value[i] < 'A' || value[i] > 'F') && (value[i] < '0' || value[i] > '9') {
|
|
385
|
+
return "", false
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return hashToken("mfa-recovery:" + value), true
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
func decodeMFAKey(raw string) ([]byte, error) {
|
|
392
|
+
value := strings.TrimSpace(raw)
|
|
393
|
+
if value == "" {
|
|
394
|
+
return nil, errors.New("empty key")
|
|
395
|
+
}
|
|
396
|
+
decoded, err := base64.StdEncoding.DecodeString(value)
|
|
397
|
+
if err != nil {
|
|
398
|
+
decoded, err = base64.RawStdEncoding.DecodeString(value)
|
|
399
|
+
}
|
|
400
|
+
if err != nil || len(decoded) != 32 {
|
|
401
|
+
return nil, errors.New("key is not 32 bytes")
|
|
402
|
+
}
|
|
403
|
+
return decoded, nil
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
func encryptMFASecret(rawKey, secret string) (string, error) {
|
|
407
|
+
key, err := decodeMFAKey(rawKey)
|
|
408
|
+
if err != nil {
|
|
409
|
+
return "", err
|
|
410
|
+
}
|
|
411
|
+
block, err := aes.NewCipher(key)
|
|
412
|
+
if err != nil {
|
|
413
|
+
return "", err
|
|
414
|
+
}
|
|
415
|
+
gcm, err := cipher.NewGCM(block)
|
|
416
|
+
if err != nil {
|
|
417
|
+
return "", err
|
|
418
|
+
}
|
|
419
|
+
nonce := make([]byte, gcm.NonceSize())
|
|
420
|
+
if _, err := rand.Read(nonce); err != nil {
|
|
421
|
+
return "", err
|
|
422
|
+
}
|
|
423
|
+
ciphertext := gcm.Seal(nil, nonce, []byte(secret), nil)
|
|
424
|
+
payload := append(nonce, ciphertext...)
|
|
425
|
+
return base64.RawStdEncoding.EncodeToString(payload), nil
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
func decryptMFASecret(rawKey, encoded string) (string, error) {
|
|
429
|
+
key, err := decodeMFAKey(rawKey)
|
|
430
|
+
if err != nil {
|
|
431
|
+
return "", err
|
|
432
|
+
}
|
|
433
|
+
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(encoded))
|
|
434
|
+
if err != nil {
|
|
435
|
+
return "", err
|
|
436
|
+
}
|
|
437
|
+
block, err := aes.NewCipher(key)
|
|
438
|
+
if err != nil {
|
|
439
|
+
return "", err
|
|
440
|
+
}
|
|
441
|
+
gcm, err := cipher.NewGCM(block)
|
|
442
|
+
if err != nil || len(payload) < gcm.NonceSize() {
|
|
443
|
+
return "", errors.New("invalid encrypted MFA secret")
|
|
444
|
+
}
|
|
445
|
+
secret, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil)
|
|
446
|
+
if err != nil {
|
|
447
|
+
return "", err
|
|
448
|
+
}
|
|
449
|
+
return string(secret), nil
|
|
450
|
+
}
|