@nakedev/go-scaffold 0.4.0 → 0.5.0
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 +598 -306
- package/dist/commands/auth.js +65 -23
- package/dist/commands/check.js +281 -0
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +33 -2
- package/dist/commands/generate.js +29 -3
- package/dist/commands/method.js +74 -63
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +21 -10
- package/dist/commands/undo.js +11 -3
- package/dist/commands/worker.js +15 -5
- package/dist/index.js +198 -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 +50 -19
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/module-manifest.js +84 -26
- package/dist/templates/rbac-manifest.js +16 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +8 -0
- package/dist/utils/auth-patcher.js +124 -33
- package/dist/utils/config.js +167 -4
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +32 -30
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/platform-patcher.js +56 -7
- package/dist/utils/rbac-patcher.js +89 -210
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +15 -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/adapters/inbound/http/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
- package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
- package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
- package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -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 +10 -5
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.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 +4 -3
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
- package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
- package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
- package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
- package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
- package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
- package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +279 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +115 -32
- package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- 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 +92 -32
- package/templates/create/features/docs/patterns.md.hbs +137 -91
- package/templates/create/features/docs/techstack.md.hbs +18 -3
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
- package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
- package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
- package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
- package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
- package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
- package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
- package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
- package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
- package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/dist/utils/method-patcher.js +0 -357
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
- package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
- package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
- package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
- package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
- package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
- package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
- package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
- package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
- package/templates/generate/module/dto.go.hbs +0 -36
- package/templates/generate/module/errors.go.hbs +0 -33
- package/templates/generate/module/handler.go.hbs +0 -134
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -48
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/service.go.hbs +0 -45
- package/templates/generate/module/minimal/service_test.go.hbs +0 -77
- package/templates/generate/module/model/model.go.hbs +0 -36
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -108
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
package user
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"context"
|
|
5
|
-
"errors"
|
|
6
|
-
"time"
|
|
7
|
-
|
|
8
|
-
"{{goModule}}/internal/app/user/model"
|
|
9
|
-
"{{goModule}}/internal/shared/tx"
|
|
10
|
-
|
|
11
|
-
"github.com/google/uuid"
|
|
12
|
-
"gorm.io/gorm"
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
type Repository struct {
|
|
16
|
-
db *gorm.DB
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
func NewRepository(db *gorm.DB) *Repository {
|
|
20
|
-
return &Repository{db: db}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
|
|
24
|
-
var u model.User
|
|
25
|
-
if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
|
|
26
|
-
return nil, err
|
|
27
|
-
}
|
|
28
|
-
return &u, nil
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
|
|
32
|
-
var u model.User
|
|
33
|
-
if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
|
|
34
|
-
return nil, err
|
|
35
|
-
}
|
|
36
|
-
return &u, nil
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
func (r *Repository) UpdateUser(ctx context.Context, u *model.User) error {
|
|
40
|
-
return tx.From(ctx, r.db).WithContext(ctx).Save(u).Error
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.User, error) {
|
|
44
|
-
var items []model.User
|
|
45
|
-
err := tx.From(ctx, r.db).WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
|
|
46
|
-
return items, err
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error) {
|
|
50
|
-
var i model.Identity
|
|
51
|
-
if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
|
|
52
|
-
return nil, err
|
|
53
|
-
}
|
|
54
|
-
return &i, nil
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error) {
|
|
58
|
-
var i model.Identity
|
|
59
|
-
if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
|
|
60
|
-
return nil, err
|
|
61
|
-
}
|
|
62
|
-
return &i, nil
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// CreateIdentity links a new login method onto an EXISTING user (e.g.
|
|
66
|
-
// Google linking onto an account that registered with a password first) —
|
|
67
|
-
// see CreateUserWithIdentity for the "brand new user" case.
|
|
68
|
-
func (r *Repository) CreateIdentity(ctx context.Context, i *model.Identity) error {
|
|
69
|
-
return tx.From(ctx, r.db).WithContext(ctx).Create(i).Error
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
func (r *Repository) UpdateIdentity(ctx context.Context, i *model.Identity) error {
|
|
73
|
-
return tx.From(ctx, r.db).WithContext(ctx).Save(i).Error
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// CreateUserWithIdentity inserts the profile and its first login method in
|
|
77
|
-
// one transaction — a user with no identity at all can't log in any way, so
|
|
78
|
-
// the two rows always exist together or not at all.
|
|
79
|
-
func (r *Repository) CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error {
|
|
80
|
-
return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
|
|
81
|
-
if err := t.Create(u).Error; err != nil {
|
|
82
|
-
return err
|
|
83
|
-
}
|
|
84
|
-
i.UserID = u.ID
|
|
85
|
-
return t.Create(i).Error
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// --- failed-login throttle -------------------------------------------------
|
|
90
|
-
//
|
|
91
|
-
// Keyed on a caller-supplied hash rather than a user id, because the counter
|
|
92
|
-
// has to work for addresses that have no account — see model.LoginThrottle.
|
|
93
|
-
|
|
94
|
-
// LoginLockedUntil reports when this key stops being locked out — zero time
|
|
95
|
-
// when it isn't. Read on every login attempt, so it stays a primary-key hit.
|
|
96
|
-
func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Time, error) {
|
|
97
|
-
var row model.LoginThrottle
|
|
98
|
-
err := tx.From(ctx, r.db).WithContext(ctx).Where("email_hash = ?", key).Take(&row).Error
|
|
99
|
-
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
100
|
-
return time.Time{}, nil
|
|
101
|
-
}
|
|
102
|
-
if err != nil || row.LockedUntil == nil {
|
|
103
|
-
return time.Time{}, err
|
|
104
|
-
}
|
|
105
|
-
return *row.LockedUntil, nil
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// RecordLoginFailure bumps the counter and pushes the lock further out,
|
|
109
|
-
// doubling each time past freeAttempts and never exceeding maxLock.
|
|
110
|
-
//
|
|
111
|
-
// One statement, so two attempts racing cannot both read the same count and
|
|
112
|
-
// write the same lock — which would let an attacker keep the backoff pinned at
|
|
113
|
-
// its first step by running attempts in parallel. Computing the interval in
|
|
114
|
-
// SQL is what buys that; a read-then-write in Go would need a transaction and
|
|
115
|
-
// a row lock to be equally safe.
|
|
116
|
-
func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
|
|
117
|
-
return tx.From(ctx, r.db).WithContext(ctx).Exec(`
|
|
118
|
-
INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
|
|
119
|
-
VALUES (?, 1, NULL, now())
|
|
120
|
-
ON CONFLICT (email_hash) DO UPDATE SET
|
|
121
|
-
failures = t.failures + 1,
|
|
122
|
-
locked_until = CASE
|
|
123
|
-
WHEN t.failures + 1 <= ? THEN NULL
|
|
124
|
-
ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
|
|
125
|
-
END,
|
|
126
|
-
updated_at = now()`,
|
|
127
|
-
key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ClearLoginFailures drops the row on a successful login, so someone who
|
|
131
|
-
// mistypes twice and then gets in starts clean rather than creeping toward a
|
|
132
|
-
// lockout over weeks.
|
|
133
|
-
func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
|
|
134
|
-
return tx.From(ctx, r.db).WithContext(ctx).
|
|
135
|
-
Where("email_hash = ?", key).
|
|
136
|
-
Delete(&model.LoginThrottle{}).Error
|
|
137
|
-
}
|
|
@@ -1,531 +0,0 @@
|
|
|
1
|
-
package user
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"context"
|
|
5
|
-
"encoding/json"
|
|
6
|
-
"errors"
|
|
7
|
-
"log/slog"
|
|
8
|
-
"net/http"
|
|
9
|
-
"strings"
|
|
10
|
-
"time"
|
|
11
|
-
|
|
12
|
-
"{{goModule}}/internal/app/user/model"
|
|
13
|
-
"{{goModule}}/internal/shared/apperror"
|
|
14
|
-
"{{goModule}}/internal/shared/config"
|
|
15
|
-
"{{goModule}}/internal/shared/dberr"
|
|
16
|
-
"{{goModule}}/internal/shared/id"
|
|
17
|
-
|
|
18
|
-
"github.com/google/uuid"
|
|
19
|
-
"golang.org/x/crypto/bcrypt"
|
|
20
|
-
"golang.org/x/oauth2"
|
|
21
|
-
"golang.org/x/oauth2/google"
|
|
22
|
-
"gorm.io/gorm"
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
// repository = what the service needs from the data layer (declared on the
|
|
26
|
-
// consumer side, so it can be faked in tests).
|
|
27
|
-
type repository interface {
|
|
28
|
-
FindByEmail(ctx context.Context, email string) (*model.User, error)
|
|
29
|
-
FindByID(ctx context.Context, id uuid.UUID) (*model.User, error)
|
|
30
|
-
UpdateUser(ctx context.Context, u *model.User) error
|
|
31
|
-
FindAll(ctx context.Context, limit, offset int) ([]model.User, error)
|
|
32
|
-
FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error)
|
|
33
|
-
FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error)
|
|
34
|
-
CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error
|
|
35
|
-
CreateIdentity(ctx context.Context, i *model.Identity) error
|
|
36
|
-
UpdateIdentity(ctx context.Context, i *model.Identity) error
|
|
37
|
-
LoginLockedUntil(ctx context.Context, key string) (time.Time, error)
|
|
38
|
-
RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error
|
|
39
|
-
ClearLoginFailures(ctx context.Context, key string) error
|
|
40
|
-
// go-scaffold:user-repository-interface
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// mailer = what the service needs to send an email — satisfied by
|
|
44
|
-
// platform/mail's AsyncClient (enqueues onto cmd/worker instead of blocking
|
|
45
|
-
// the request on SMTP). Takes ctx so the enqueue can join the caller's
|
|
46
|
-
// transaction where the queue backend supports it.
|
|
47
|
-
type mailer interface {
|
|
48
|
-
Send(ctx context.Context, to, subject, body string) error
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// go-scaffold:user-interfaces
|
|
52
|
-
|
|
53
|
-
type Service struct {
|
|
54
|
-
repo repository
|
|
55
|
-
tokens tokenStore
|
|
56
|
-
mailer mailer
|
|
57
|
-
jwtSecret string
|
|
58
|
-
accessTTL time.Duration
|
|
59
|
-
refreshTTL time.Duration
|
|
60
|
-
resetTTL time.Duration
|
|
61
|
-
resetURL string
|
|
62
|
-
verifyTTL time.Duration
|
|
63
|
-
verifyURL string
|
|
64
|
-
googleOAuth *oauth2.Config
|
|
65
|
-
// go-scaffold:user-service-fields
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
func NewService(
|
|
69
|
-
repo repository,
|
|
70
|
-
tokens tokenStore,
|
|
71
|
-
mailer mailer,
|
|
72
|
-
cfg config.Config,
|
|
73
|
-
// go-scaffold:user-service-params
|
|
74
|
-
) *Service {
|
|
75
|
-
return &Service{
|
|
76
|
-
repo: repo,
|
|
77
|
-
tokens: tokens,
|
|
78
|
-
mailer: mailer,
|
|
79
|
-
jwtSecret: cfg.JWTSecret,
|
|
80
|
-
accessTTL: cfg.JWTAccessTTL,
|
|
81
|
-
refreshTTL: cfg.JWTRefreshTTL,
|
|
82
|
-
resetTTL: cfg.PasswordResetTTL,
|
|
83
|
-
resetURL: cfg.PasswordResetURL,
|
|
84
|
-
verifyTTL: cfg.EmailVerifyTTL,
|
|
85
|
-
verifyURL: cfg.EmailVerifyURL,
|
|
86
|
-
googleOAuth: &oauth2.Config{
|
|
87
|
-
ClientID: cfg.GoogleClientID,
|
|
88
|
-
ClientSecret: cfg.GoogleClientSecret,
|
|
89
|
-
RedirectURL: cfg.GoogleRedirectURL,
|
|
90
|
-
Scopes: []string{"openid", "email", "profile"},
|
|
91
|
-
Endpoint: google.Endpoint,
|
|
92
|
-
},
|
|
93
|
-
// go-scaffold:user-service-init
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// normalizeEmail is applied at every boundary an address enters the service
|
|
98
|
-
// through. The column is a plain case-sensitive UNIQUE, so without this
|
|
99
|
-
// "Foo@x.com" and "foo@x.com" are two accounts that never collide — and a
|
|
100
|
-
// Google login (Google always reports lowercase) fails to find the local
|
|
101
|
-
// account it should have linked to, silently creating a second user for the
|
|
102
|
-
// same person.
|
|
103
|
-
func normalizeEmail(email string) string {
|
|
104
|
-
return strings.ToLower(strings.TrimSpace(email))
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Failed-attempt policy. Constants rather than config: these are a security
|
|
108
|
-
// posture, not something to tune per environment, and every knob added here is
|
|
109
|
-
// a knob someone can quietly widen until the control stops working. Change the
|
|
110
|
-
// numbers if your threat model differs.
|
|
111
|
-
//
|
|
112
|
-
// The first loginFreeAttempts failures cost nothing — a typo shouldn't lock
|
|
113
|
-
// anyone out. After that each failure doubles the wait (1s, 2s, 4s, ...) up to
|
|
114
|
-
// loginMaxLock, which is the shape OWASP's Authentication Cheat Sheet asks for.
|
|
115
|
-
const (
|
|
116
|
-
loginFreeAttempts = 3
|
|
117
|
-
loginMaxLock = 15 * time.Minute
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
// throttleKey namespaces the counter by what is being attempted, so a locked
|
|
121
|
-
// login never blocks the password-reset that would fix it — someone who forgot
|
|
122
|
-
// their password is exactly the person who trips the login counter.
|
|
123
|
-
//
|
|
124
|
-
// Hashed, because the counter must exist for addresses that have no account
|
|
125
|
-
// (otherwise "was I throttled" answers "does this account exist"), and a table
|
|
126
|
-
// of plain addresses that anyone has ever typed is a user list.
|
|
127
|
-
func throttleKey(purpose, email string) string {
|
|
128
|
-
return hashToken(purpose + ":" + normalizeEmail(email))
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// throttled reports whether this key is inside its lockout window. A failure
|
|
132
|
-
// to read the counter is treated as not-throttled: this is a brake, and it
|
|
133
|
-
// should not be able to lock everybody out on its own.
|
|
134
|
-
func (s *Service) throttled(ctx context.Context, key string) bool {
|
|
135
|
-
until, err := s.repo.LoginLockedUntil(ctx, key)
|
|
136
|
-
if err != nil {
|
|
137
|
-
slog.Error("read login throttle", "error", err)
|
|
138
|
-
return false
|
|
139
|
-
}
|
|
140
|
-
return !until.IsZero() && time.Now().Before(until)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse, error) {
|
|
144
|
-
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
|
145
|
-
if err != nil {
|
|
146
|
-
return nil, apperror.NewInternal()
|
|
147
|
-
}
|
|
148
|
-
hashStr := string(hash)
|
|
149
|
-
|
|
150
|
-
u := &model.User{ID: id.New(), Email: normalizeEmail(in.Email), Name: in.Name}
|
|
151
|
-
i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
|
|
152
|
-
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
153
|
-
if dberr.IsDuplicate(err) {
|
|
154
|
-
return nil, errEmailTaken()
|
|
155
|
-
}
|
|
156
|
-
return nil, apperror.NewInternal()
|
|
157
|
-
}
|
|
158
|
-
// best-effort: a mail failure shouldn't block registration — the user can
|
|
159
|
-
// always ask for another link via ResendVerificationEmail.
|
|
160
|
-
s.sendVerificationEmail(ctx, u)
|
|
161
|
-
return s.issueTokens(ctx, u)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
func (s *Service) Login(ctx context.Context, in loginInput) (*authResponse, error) {
|
|
165
|
-
key := throttleKey("login", in.Email)
|
|
166
|
-
if s.throttled(ctx, key) {
|
|
167
|
-
return nil, errTooManyAttempts()
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Every failure below is recorded against the same key whether or not the
|
|
171
|
-
// account exists, and answers with the same error — so the counter cannot
|
|
172
|
-
// be used to enumerate accounts either.
|
|
173
|
-
fail := func() (*authResponse, error) {
|
|
174
|
-
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
175
|
-
slog.Error("record login failure", "error", err)
|
|
176
|
-
}
|
|
177
|
-
return nil, errInvalidCredentials()
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
u, err := s.repo.FindByEmail(ctx, normalizeEmail(in.Email))
|
|
181
|
-
if err != nil {
|
|
182
|
-
return fail()
|
|
183
|
-
}
|
|
184
|
-
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
185
|
-
if err != nil || ident.PasswordHash == nil {
|
|
186
|
-
return fail()
|
|
187
|
-
}
|
|
188
|
-
if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
|
|
189
|
-
return fail()
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
|
|
193
|
-
slog.Error("clear login failures", "error", err)
|
|
194
|
-
}
|
|
195
|
-
return s.issueTokens(ctx, u)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Refresh rotates a refresh token: the presented one is consumed (deleted +
|
|
199
|
-
// tombstoned as "used") and a fresh pair is issued. If the presented token
|
|
200
|
-
// isn't active but WAS already used, that means this exact raw value got
|
|
201
|
-
// replayed after rotation — i.e. it leaked — so every session for that user
|
|
202
|
-
// is revoked, not just this one.
|
|
203
|
-
func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*authResponse, error) {
|
|
204
|
-
hash := hashToken(rawRefreshToken)
|
|
205
|
-
userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
|
|
206
|
-
if err != nil {
|
|
207
|
-
return nil, apperror.NewInternal()
|
|
208
|
-
}
|
|
209
|
-
if !ok {
|
|
210
|
-
if reusedBy, used, uerr := s.tokens.IsRefreshTokenUsed(ctx, hash); uerr == nil && used {
|
|
211
|
-
_ = s.tokens.RevokeAllRefreshTokens(ctx, reusedBy)
|
|
212
|
-
}
|
|
213
|
-
return nil, errInvalidToken()
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if err := s.tokens.DeleteRefreshToken(ctx, hash, userID); err != nil {
|
|
217
|
-
return nil, apperror.NewInternal()
|
|
218
|
-
}
|
|
219
|
-
if err := s.tokens.MarkRefreshTokenUsed(ctx, hash, userID, s.refreshTTL); err != nil {
|
|
220
|
-
return nil, apperror.NewInternal()
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
u, err := s.repo.FindByID(ctx, userID)
|
|
224
|
-
if err != nil {
|
|
225
|
-
return nil, errInvalidToken()
|
|
226
|
-
}
|
|
227
|
-
return s.issueTokens(ctx, u)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Logout is intentional, not reuse — no tombstoning, and idempotent (a
|
|
231
|
-
// missing/already-gone token still succeeds).
|
|
232
|
-
func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
|
|
233
|
-
if rawRefreshToken == "" {
|
|
234
|
-
return nil
|
|
235
|
-
}
|
|
236
|
-
hash := hashToken(rawRefreshToken)
|
|
237
|
-
userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
|
|
238
|
-
if err != nil || !ok {
|
|
239
|
-
return nil
|
|
240
|
-
}
|
|
241
|
-
return s.tokens.DeleteRefreshToken(ctx, hash, userID)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// LogoutAll is the authenticated, self-service version of what ResetPassword
|
|
245
|
-
// already does automatically: end every session for this user, not just the
|
|
246
|
-
// one making the request — e.g. "log out everywhere" after a lost device.
|
|
247
|
-
func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
|
|
248
|
-
return s.tokens.RevokeAllRefreshTokens(ctx, userID)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*model.User, error) {
|
|
252
|
-
u, err := s.repo.FindByID(ctx, userID)
|
|
253
|
-
if err != nil {
|
|
254
|
-
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
255
|
-
return nil, errNotFound()
|
|
256
|
-
}
|
|
257
|
-
return nil, apperror.NewInternal()
|
|
258
|
-
}
|
|
259
|
-
return u, nil
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// List is unused until an admin-facing route calls it (see `add rbac`'s
|
|
263
|
-
// GET /users) — kept here rather than gated behind a patch since it's a
|
|
264
|
-
// generically useful piece of data access, same as UpdateUser.
|
|
265
|
-
func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, error) {
|
|
266
|
-
items, err := s.repo.FindAll(ctx, limit, offset)
|
|
267
|
-
if err != nil {
|
|
268
|
-
return nil, apperror.NewInternal()
|
|
269
|
-
}
|
|
270
|
-
return items, nil
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ForgotPassword always succeeds from the caller's point of view — whether
|
|
274
|
-
// the email exists or not — so a login-attempt-shaped probe can't be used to
|
|
275
|
-
// enumerate registered accounts. Only if the email resolves to a local
|
|
276
|
-
// (password-based) identity does it actually issue+email a reset token.
|
|
277
|
-
func (s *Service) ForgotPassword(ctx context.Context, email string) error {
|
|
278
|
-
// Its own counter, so hammering this endpoint cannot lock anyone out of
|
|
279
|
-
// logging in, and a locked-out login cannot block the reset that fixes it.
|
|
280
|
-
// Every request counts here, not just failures: what this throttles is
|
|
281
|
-
// using someone else's address as a mail-bomb target.
|
|
282
|
-
key := throttleKey("pwreset", email)
|
|
283
|
-
if s.throttled(ctx, key) {
|
|
284
|
-
return nil // same answer as always — silence is the whole design here
|
|
285
|
-
}
|
|
286
|
-
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
287
|
-
slog.Error("record password reset attempt", "error", err)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
u, err := s.repo.FindByEmail(ctx, normalizeEmail(email))
|
|
291
|
-
if err != nil {
|
|
292
|
-
return nil
|
|
293
|
-
}
|
|
294
|
-
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
295
|
-
if err != nil || ident.PasswordHash == nil {
|
|
296
|
-
return nil // google-only account has no password to reset
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
raw, err := randomToken()
|
|
300
|
-
if err != nil {
|
|
301
|
-
return apperror.NewInternal()
|
|
302
|
-
}
|
|
303
|
-
if err := s.tokens.SetPasswordResetToken(ctx, hashToken(raw), u.ID, s.resetTTL); err != nil {
|
|
304
|
-
return apperror.NewInternal()
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
link := s.resetURL + "?token=" + raw
|
|
308
|
-
if err := s.mailer.Send(ctx, u.Email, "Reset your password", "Reset your password: "+link); err != nil {
|
|
309
|
-
// token's already stored — a mail failure shouldn't fail the request,
|
|
310
|
-
// just get logged so it's visible operationally.
|
|
311
|
-
slog.Error("send password reset email", "error", err)
|
|
312
|
-
}
|
|
313
|
-
return nil
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ResetPassword consumes a one-time reset token (GETDEL — see
|
|
317
|
-
// tokenStore.ConsumePasswordResetToken), sets the new password, and revokes
|
|
318
|
-
// every existing session for that user: a password reset should end every
|
|
319
|
-
// session an attacker (or the legitimate user on another device) still holds.
|
|
320
|
-
func (s *Service) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
|
|
321
|
-
userID, ok, err := s.tokens.ConsumePasswordResetToken(ctx, hashToken(rawToken))
|
|
322
|
-
if err != nil {
|
|
323
|
-
return apperror.NewInternal()
|
|
324
|
-
}
|
|
325
|
-
if !ok {
|
|
326
|
-
return errInvalidToken()
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
330
|
-
if err != nil {
|
|
331
|
-
return apperror.NewInternal()
|
|
332
|
-
}
|
|
333
|
-
hashStr := string(hash)
|
|
334
|
-
|
|
335
|
-
ident, err := s.repo.FindIdentity(ctx, userID, model.ProviderLocal)
|
|
336
|
-
if err != nil {
|
|
337
|
-
return apperror.NewInternal()
|
|
338
|
-
}
|
|
339
|
-
ident.PasswordHash = &hashStr
|
|
340
|
-
if err := s.repo.UpdateIdentity(ctx, ident); err != nil {
|
|
341
|
-
return apperror.NewInternal()
|
|
342
|
-
}
|
|
343
|
-
return s.tokens.RevokeAllRefreshTokens(ctx, userID)
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// sendVerificationEmail issues a one-time token and emails the link — called
|
|
347
|
-
// from Register (best-effort) and ResendVerificationEmail. Not exported:
|
|
348
|
-
// callers that want to send one go through one of those two, which decide
|
|
349
|
-
// whether it's appropriate to (e.g. ResendVerificationEmail checks the user
|
|
350
|
-
// isn't already verified first).
|
|
351
|
-
func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
|
|
352
|
-
raw, err := randomToken()
|
|
353
|
-
if err != nil {
|
|
354
|
-
slog.Error("generate email verification token", "error", err)
|
|
355
|
-
return
|
|
356
|
-
}
|
|
357
|
-
if err := s.tokens.SetEmailVerifyToken(ctx, hashToken(raw), u.ID, s.verifyTTL); err != nil {
|
|
358
|
-
slog.Error("store email verification token", "error", err)
|
|
359
|
-
return
|
|
360
|
-
}
|
|
361
|
-
link := s.verifyURL + "?token=" + raw
|
|
362
|
-
if err := s.mailer.Send(ctx, u.Email, "Verify your email", "Verify your email: "+link); err != nil {
|
|
363
|
-
slog.Error("send email verification email", "error", err)
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// ResendVerificationEmail is the authenticated counterpart to the automatic
|
|
368
|
-
// send in Register — for when the first email never arrived or its token
|
|
369
|
-
// expired. Rejects an already-verified user instead of silently no-op'ing:
|
|
370
|
-
// unlike ForgotPassword this is authenticated, so there's no enumeration
|
|
371
|
-
// concern in telling the caller their own account's real state.
|
|
372
|
-
func (s *Service) ResendVerificationEmail(ctx context.Context, userID uuid.UUID) error {
|
|
373
|
-
u, err := s.repo.FindByID(ctx, userID)
|
|
374
|
-
if err != nil {
|
|
375
|
-
return apperror.NewInternal()
|
|
376
|
-
}
|
|
377
|
-
if u.EmailVerified {
|
|
378
|
-
return errAlreadyVerified()
|
|
379
|
-
}
|
|
380
|
-
s.sendVerificationEmail(ctx, u)
|
|
381
|
-
return nil
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// VerifyEmail consumes a one-time verification token (GETDEL — see
|
|
385
|
-
// tokenStore.ConsumeEmailVerifyToken) and marks the user verified.
|
|
386
|
-
func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
|
|
387
|
-
userID, ok, err := s.tokens.ConsumeEmailVerifyToken(ctx, hashToken(rawToken))
|
|
388
|
-
if err != nil {
|
|
389
|
-
return apperror.NewInternal()
|
|
390
|
-
}
|
|
391
|
-
if !ok {
|
|
392
|
-
return errInvalidToken()
|
|
393
|
-
}
|
|
394
|
-
u, err := s.repo.FindByID(ctx, userID)
|
|
395
|
-
if err != nil {
|
|
396
|
-
return apperror.NewInternal()
|
|
397
|
-
}
|
|
398
|
-
u.EmailVerified = true
|
|
399
|
-
return s.repo.UpdateUser(ctx, u)
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// GoogleLoginURL signs a short-lived CSRF state token and builds the redirect
|
|
403
|
-
// URL. The second return value is the nonce the caller must set as a cookie:
|
|
404
|
-
// the callback checks it against the one inside the state (see jwt.go), which
|
|
405
|
-
// is what binds the flow to this one browser.
|
|
406
|
-
func (s *Service) GoogleLoginURL() (string, string, error) {
|
|
407
|
-
state, nonce, err := s.issueOAuthState()
|
|
408
|
-
if err != nil {
|
|
409
|
-
return "", "", apperror.NewInternal()
|
|
410
|
-
}
|
|
411
|
-
return s.googleOAuth.AuthCodeURL(state), nonce, nil
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
type googleUserInfo struct {
|
|
415
|
-
Sub string `json:"sub"`
|
|
416
|
-
Email string `json:"email"`
|
|
417
|
-
EmailVerified bool `json:"email_verified"`
|
|
418
|
-
Name string `json:"name"`
|
|
419
|
-
Picture string `json:"picture"`
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
func (s *Service) GoogleCallback(ctx context.Context, code, state, nonce string) (*authResponse, error) {
|
|
423
|
-
if err := s.verifyOAuthState(state, nonce); err != nil {
|
|
424
|
-
return nil, err
|
|
425
|
-
}
|
|
426
|
-
tok, err := s.googleOAuth.Exchange(ctx, code)
|
|
427
|
-
if err != nil {
|
|
428
|
-
return nil, errInvalidToken()
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
resp, err := s.googleOAuth.Client(ctx, tok).Get("https://openidconnect.googleapis.com/v1/userinfo")
|
|
432
|
-
if err != nil {
|
|
433
|
-
return nil, apperror.NewInternal()
|
|
434
|
-
}
|
|
435
|
-
defer func() { _ = resp.Body.Close() }()
|
|
436
|
-
if resp.StatusCode != http.StatusOK {
|
|
437
|
-
return nil, apperror.NewInternal()
|
|
438
|
-
}
|
|
439
|
-
var info googleUserInfo
|
|
440
|
-
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
441
|
-
return nil, apperror.NewInternal()
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
u, err := s.findOrCreateGoogleUser(ctx, info)
|
|
445
|
-
if err != nil {
|
|
446
|
-
return nil, err
|
|
447
|
-
}
|
|
448
|
-
return s.issueTokens(ctx, u)
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// findOrCreateGoogleUser: (1) an existing google identity for this sub wins
|
|
452
|
-
// outright; (2) failing that, ONLY if Google says the email is verified, link
|
|
453
|
-
// a new google identity onto an existing local account with that email — an
|
|
454
|
-
// unverified email can't be used to take over someone else's account; (3)
|
|
455
|
-
// otherwise, brand new user + identity.
|
|
456
|
-
func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInfo) (*model.User, error) {
|
|
457
|
-
if ident, err := s.repo.FindIdentityByProviderUID(ctx, model.ProviderGoogle, info.Sub); err == nil {
|
|
458
|
-
return s.repo.FindByID(ctx, ident.UserID)
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
providerUID := info.Sub
|
|
462
|
-
email := normalizeEmail(info.Email)
|
|
463
|
-
if info.EmailVerified {
|
|
464
|
-
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
465
|
-
ident := &model.Identity{ID: id.New(), UserID: u.ID, Provider: model.ProviderGoogle, ProviderUID: &providerUID}
|
|
466
|
-
if err := s.repo.CreateIdentity(ctx, ident); err != nil {
|
|
467
|
-
return nil, apperror.NewInternal()
|
|
468
|
-
}
|
|
469
|
-
return u, nil
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
u := &model.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.Picture, EmailVerified: info.EmailVerified}
|
|
474
|
-
ident := &model.Identity{ID: id.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID}
|
|
475
|
-
if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
|
|
476
|
-
if dberr.IsDuplicate(err) {
|
|
477
|
-
return nil, errEmailTaken()
|
|
478
|
-
}
|
|
479
|
-
return nil, apperror.NewInternal()
|
|
480
|
-
}
|
|
481
|
-
return u, nil
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// EnsureUser is idempotent: an existing user with this email is returned
|
|
485
|
-
// as-is (password untouched — cmd/seed shouldn't silently reset a real
|
|
486
|
-
// password on every deploy), otherwise a new local user+identity is
|
|
487
|
-
// created. Used by cmd/seed only — nothing in the HTTP API calls this.
|
|
488
|
-
func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*model.User, error) {
|
|
489
|
-
email = normalizeEmail(email)
|
|
490
|
-
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
491
|
-
return u, nil
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
495
|
-
if err != nil {
|
|
496
|
-
return nil, apperror.NewInternal()
|
|
497
|
-
}
|
|
498
|
-
hashStr := string(hash)
|
|
499
|
-
|
|
500
|
-
u := &model.User{ID: id.New(), Email: email, Name: name}
|
|
501
|
-
i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
|
|
502
|
-
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
503
|
-
return nil, apperror.NewInternal()
|
|
504
|
-
}
|
|
505
|
-
return u, nil
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// go-scaffold:user-service-methods
|
|
509
|
-
|
|
510
|
-
func (s *Service) issueTokens(ctx context.Context, u *model.User) (*authResponse, error) {
|
|
511
|
-
access, err := s.issueAccessToken(
|
|
512
|
-
u.ID,
|
|
513
|
-
// go-scaffold:issue-access-token-args
|
|
514
|
-
)
|
|
515
|
-
if err != nil {
|
|
516
|
-
return nil, apperror.NewInternal()
|
|
517
|
-
}
|
|
518
|
-
refresh, err := randomToken()
|
|
519
|
-
if err != nil {
|
|
520
|
-
return nil, apperror.NewInternal()
|
|
521
|
-
}
|
|
522
|
-
if err := s.tokens.SetRefreshToken(ctx, hashToken(refresh), u.ID, s.refreshTTL); err != nil {
|
|
523
|
-
return nil, apperror.NewInternal()
|
|
524
|
-
}
|
|
525
|
-
return &authResponse{
|
|
526
|
-
AccessToken: access,
|
|
527
|
-
RefreshToken: refresh,
|
|
528
|
-
TokenType: "Bearer",
|
|
529
|
-
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
530
|
-
}, nil
|
|
531
|
-
}
|