From 2c483e25874e33c98cd7489594e65bb7051488a9 Mon Sep 17 00:00:00 2001 From: osama1998H Date: Sat, 28 Mar 2026 20:15:04 +0300 Subject: [PATCH] feat: add email verification flow Add complete email verification with token-based confirmation, auto-send on registration, and JWT-protected resend endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 3 +- CLAUDE.md | 2 + README.md | 6 +- docs/docs.go | 203 +++++++++++++++ docs/swagger.json | 203 +++++++++++++++ docs/swagger.yaml | 138 ++++++++++ internal/api/handlers/auth.go | 65 +++++ internal/api/handlers/response.go | 2 + internal/api/handlers/swagger_types.go | 5 + internal/api/router.go | 2 + internal/config/config.go | 11 +- internal/domain/audit.go | 4 +- internal/domain/errors.go | 3 +- .../postgres/email_verifications.go | 62 +++++ internal/repository/postgres/users.go | 19 ++ internal/service/auth.go | 88 +++++++ internal/service/auth_verify_test.go | 239 ++++++++++++++++++ internal/service/email.go | 24 ++ .../000004_email_verification_tokens.down.sql | 1 + .../000004_email_verification_tokens.up.sql | 8 + sql/queries/email_verifications.sql | 18 ++ sql/queries/users.sql | 3 + sql/schema.sql | 10 + 23 files changed, 1111 insertions(+), 8 deletions(-) create mode 100644 internal/repository/postgres/email_verifications.go create mode 100644 internal/service/auth_verify_test.go create mode 100644 migrations/000004_email_verification_tokens.down.sql create mode 100644 migrations/000004_email_verification_tokens.up.sql create mode 100644 sql/queries/email_verifications.sql diff --git a/.env.example b/.env.example index 64c24e5..4ce33a1 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,7 @@ JWT_SECRET= ACCESS_TOKEN_DURATION=15m REFRESH_TOKEN_DURATION=168h # 7 days RESET_TOKEN_DURATION=1h +VERIFY_EMAIL_TOKEN_DURATION=24h RATE_LIMIT_PER_MINUTE=60 # ─── CORS ───────────────────────────────────────────────────────────────────── @@ -27,7 +28,7 @@ RATE_LIMIT_PER_MINUTE=60 CORS_ORIGINS=* # ─── Email (SMTP) ───────────────────────────────────────────────────────────── -# Leave SMTP_HOST empty to disable password reset email delivery safely. +# Leave SMTP_HOST empty to disable password reset and email verification delivery safely. # For local development, prefer MailHog via docker-compose. SMTP_HOST= SMTP_PORT=587 diff --git a/CLAUDE.md b/CLAUDE.md index 05aae8e..ee7c722 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,7 @@ All API routes live under `/api/v1/`. Public routes need no auth; protected rout | `users` | Auth subjects; email unique per org | | `sessions` | Refresh token hashes (never store raw tokens) | | `password_reset_tokens` | One-time reset tokens (hashed) | +| `email_verification_tokens` | One-time email verification tokens (hashed) | | `roles` | RBAC roles per org | | `permissions` | System-level named permissions (seeded) | | `role_permissions` | Many-to-many role ↔ permission | @@ -196,6 +197,7 @@ All configuration is loaded from `.env` (or environment variables) via Viper. Se | `JWT_SECRET` | — | Required; min 32 chars | | `ACCESS_TOKEN_DURATION` | `15m` | Short-lived access token TTL | | `REFRESH_TOKEN_DURATION` | `168h` | Refresh token TTL (7 days) | +| `VERIFY_EMAIL_TOKEN_DURATION` | `24h` | Email verification token TTL | | `RATE_LIMIT_PER_MINUTE` | `60` | Requests per IP per minute | | `CORS_ORIGINS` | `*` | Comma-separated allowed origins | | `SMTP_*` | — | Optional; prints emails to stdout if unset | diff --git a/README.md b/README.md index e233572..e72daf3 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ curl http://localhost:8080/api/v1/users/me \ - **API key lifecycle management** to issue, scope, revoke, and audit keys - **Audit logs** for authentication and authorization events - **Webhooks** for auth events with HMAC signatures +- **Email verification** with one-time token links sent via SMTP - **Password reset** via SMTP - **Rate limiting** backed by Redis - **Health and readiness endpoints** for orchestration @@ -128,6 +129,7 @@ All configuration is via environment variables (see `.env.example`): | `ACCESS_TOKEN_DURATION` | `15m` | Access token lifetime | | `REFRESH_TOKEN_DURATION` | `168h` | Refresh token lifetime (7 days) | | `RESET_TOKEN_DURATION` | `1h` | Password reset token lifetime | +| `VERIFY_EMAIL_TOKEN_DURATION` | `24h` | Email verification token lifetime | | `RATE_LIMIT_PER_MINUTE` | `60` | Max requests per IP per minute | | `CORS_ORIGINS` | `*` | Comma-separated allowed origins | | `SMTP_HOST` | — | SMTP server for password reset delivery | @@ -153,6 +155,8 @@ Protected routes currently require `Authorization: Bearer `. Refre | `POST` | `/api/v1/auth/password/reset-request` | None | Request password reset email | | `POST` | `/api/v1/auth/password/reset-confirm` | None | Confirm reset with token | | `PUT` | `/api/v1/auth/password/change` | JWT | Change password | +| `POST` | `/api/v1/auth/email/verify-request` | JWT | Resend verification email | +| `POST` | `/api/v1/auth/email/verify-confirm` | None | Confirm email with token | | `GET` | `/api/v1/users/me` | JWT | Get own profile | | `PUT` | `/api/v1/users/me` | JWT | Update own profile | | `GET` | `/api/v1/users` | JWT | List org users | @@ -193,7 +197,7 @@ Protected routes currently require `Authorization: Bearer `. Refre ## Roadmap - [ ] Scoped API-key auth on protected routes -- [ ] Email verification flow +- [x] Email verification flow - [ ] Multi-Factor Authentication (TOTP) - [ ] OAuth2 provider (use UniAuth as your OAuth2 server) - [ ] Social login (Google, GitHub) diff --git a/docs/docs.go b/docs/docs.go index 2b43bb9..c7476cb 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -272,6 +272,107 @@ const docTemplate = `{ } } }, + "/api/v1/auth/email/verify-confirm": { + "post": { + "description": "Validates the one-time verification token and marks the user's email as verified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Confirm email verification", + "parameters": [ + { + "description": "Verification token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api_handlers.VerifyEmailConfirmBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerMessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "401": { + "description": "Token invalid or expired", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + } + } + } + }, + "/api/v1/auth/email/verify-request": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Sends a verification email to the authenticated user. Returns 409 if the email is already verified.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Request email verification", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerMessageResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "403": { + "description": "Account is inactive", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "409": { + "description": "Email already verified", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + } + } + } + }, "/api/v1/auth/login": { "post": { "description": "Validates credentials and returns a JWT access token and a refresh token. Only the access token is accepted in the Authorization header.", @@ -1257,6 +1358,73 @@ const docTemplate = `{ } } } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new user inside the caller's organization. The organization is derived from the JWT — no org_id is accepted in the request body. New users always default to non-superuser. Optionally assign initial roles at creation time. Requires the ` + "`" + `users:write` + "`" + ` permission.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Create a user in the organization", + "parameters": [ + { + "description": "User creation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api_handlers.CreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_api_handlers.UserView" + } + }, + "400": { + "description": "Validation error (weak password, empty email, invalid role ID)", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "403": { + "description": "Missing users:write permission", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "409": { + "description": "Email already exists in organization", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + } + } } }, "/api/v1/users/me": { @@ -2086,6 +2254,32 @@ const docTemplate = `{ } } }, + "internal_api_handlers.CreateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "employee@acme.com" + }, + "full_name": { + "type": "string", + "example": "John Doe" + }, + "password": { + "type": "string", + "example": "S3cur3P@ss!" + }, + "role_ids": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002" + ] + } + } + }, "internal_api_handlers.CreateWebhookRequest": { "type": "object", "properties": { @@ -2504,6 +2698,15 @@ const docTemplate = `{ } } }, + "internal_api_handlers.VerifyEmailConfirmBody": { + "type": "object", + "properties": { + "token": { + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440099" + } + } + }, "internal_api_handlers.WebhookListResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 919a489..af19111 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -266,6 +266,107 @@ } } }, + "/api/v1/auth/email/verify-confirm": { + "post": { + "description": "Validates the one-time verification token and marks the user's email as verified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Confirm email verification", + "parameters": [ + { + "description": "Verification token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api_handlers.VerifyEmailConfirmBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerMessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "401": { + "description": "Token invalid or expired", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + } + } + } + }, + "/api/v1/auth/email/verify-request": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Sends a verification email to the authenticated user. Returns 409 if the email is already verified.", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Request email verification", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerMessageResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "403": { + "description": "Account is inactive", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "409": { + "description": "Email already verified", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + } + } + } + }, "/api/v1/auth/login": { "post": { "description": "Validates credentials and returns a JWT access token and a refresh token. Only the access token is accepted in the Authorization header.", @@ -1251,6 +1352,73 @@ } } } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new user inside the caller's organization. The organization is derived from the JWT — no org_id is accepted in the request body. New users always default to non-superuser. Optionally assign initial roles at creation time. Requires the `users:write` permission.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Create a user in the organization", + "parameters": [ + { + "description": "User creation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api_handlers.CreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_api_handlers.UserView" + } + }, + "400": { + "description": "Validation error (weak password, empty email, invalid role ID)", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "403": { + "description": "Missing users:write permission", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "409": { + "description": "Email already exists in organization", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.SwaggerErrorResponse" + } + } + } } }, "/api/v1/users/me": { @@ -2080,6 +2248,32 @@ } } }, + "internal_api_handlers.CreateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "employee@acme.com" + }, + "full_name": { + "type": "string", + "example": "John Doe" + }, + "password": { + "type": "string", + "example": "S3cur3P@ss!" + }, + "role_ids": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002" + ] + } + } + }, "internal_api_handlers.CreateWebhookRequest": { "type": "object", "properties": { @@ -2498,6 +2692,15 @@ } } }, + "internal_api_handlers.VerifyEmailConfirmBody": { + "type": "object", + "properties": { + "token": { + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440099" + } + } + }, "internal_api_handlers.WebhookListResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c7585cc..0d235ad 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -165,6 +165,24 @@ definitions: example: editor type: string type: object + internal_api_handlers.CreateUserRequest: + properties: + email: + example: employee@acme.com + type: string + full_name: + example: John Doe + type: string + password: + example: S3cur3P@ss! + type: string + role_ids: + example: + - 550e8400-e29b-41d4-a716-446655440002 + items: + type: string + type: array + type: object internal_api_handlers.CreateWebhookRequest: properties: events: @@ -456,6 +474,12 @@ definitions: example: "2026-03-01T12:00:00Z" type: string type: object + internal_api_handlers.VerifyEmailConfirmBody: + properties: + token: + example: 550e8400-e29b-41d4-a716-446655440099 + type: string + type: object internal_api_handlers.WebhookListResponse: properties: webhooks: @@ -666,6 +690,73 @@ paths: summary: List audit logs tags: - Audit + /api/v1/auth/email/verify-confirm: + post: + consumes: + - application/json + description: Validates the one-time verification token and marks the user's + email as verified. + parameters: + - description: Verification token + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_api_handlers.VerifyEmailConfirmBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerMessageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "401": + description: Token invalid or expired + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + summary: Confirm email verification + tags: + - Auth + /api/v1/auth/email/verify-request: + post: + description: Sends a verification email to the authenticated user. Returns 409 + if the email is already verified. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerMessageResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "403": + description: Account is inactive + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "409": + description: Email already verified + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + security: + - BearerAuth: [] + summary: Request email verification + tags: + - Auth /api/v1/auth/login: post: consumes: @@ -1317,6 +1408,53 @@ paths: summary: List users in organization tags: - Users + post: + consumes: + - application/json + description: Creates a new user inside the caller's organization. The organization + is derived from the JWT — no org_id is accepted in the request body. New users + always default to non-superuser. Optionally assign initial roles at creation + time. Requires the `users:write` permission. + parameters: + - description: User creation payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_api_handlers.CreateUserRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_api_handlers.UserView' + "400": + description: Validation error (weak password, empty email, invalid role + ID) + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "403": + description: Missing users:write permission + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "409": + description: Email already exists in organization + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.SwaggerErrorResponse' + security: + - BearerAuth: [] + summary: Create a user in the organization + tags: + - Users /api/v1/users/{id}: delete: description: Marks the specified user as inactive. Deactivated users cannot diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 417fe2a..6804e78 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -335,6 +335,71 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"message": "password changed successfully"}) } +// RequestEmailVerification godoc +// @Summary Request email verification +// @Description Sends a verification email to the authenticated user. Returns 409 if the email is already verified. +// @Tags Auth +// @Produce json +// @Success 200 {object} SwaggerMessageResponse +// @Failure 401 {object} SwaggerErrorResponse +// @Failure 403 {object} SwaggerErrorResponse "Account is inactive" +// @Failure 409 {object} SwaggerErrorResponse "Email already verified" +// @Failure 500 {object} SwaggerErrorResponse +// @Security BearerAuth +// @Router /api/v1/auth/email/verify-request [post] +func (h *AuthHandler) RequestEmailVerification(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + orgID, ok := middleware.GetOrgID(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + if err := h.authSvc.RequestEmailVerification(r.Context(), userID, orgID); err != nil { + handleServiceError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "verification email sent"}) +} + +// ConfirmEmailVerification godoc +// @Summary Confirm email verification +// @Description Validates the one-time verification token and marks the user's email as verified. +// @Tags Auth +// @Accept json +// @Produce json +// @Param body body VerifyEmailConfirmBody true "Verification token" +// @Success 200 {object} SwaggerMessageResponse +// @Failure 400 {object} SwaggerErrorResponse +// @Failure 401 {object} SwaggerErrorResponse "Token invalid or expired" +// @Failure 500 {object} SwaggerErrorResponse +// @Router /api/v1/auth/email/verify-confirm [post] +func (h *AuthHandler) ConfirmEmailVerification(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Token == "" { + writeError(w, http.StatusBadRequest, "token is required") + return + } + + if err := h.authSvc.ConfirmEmailVerification(r.Context(), req.Token); err != nil { + handleServiceError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "email verified successfully"}) +} + func ptrStr(s string) *string { if s == "" { return nil diff --git a/internal/api/handlers/response.go b/internal/api/handlers/response.go index 64c9f85..95afe95 100644 --- a/internal/api/handlers/response.go +++ b/internal/api/handlers/response.go @@ -45,6 +45,8 @@ func handleServiceError(w http.ResponseWriter, err error) { writeError(w, http.StatusBadRequest, err.Error()) case errors.Is(err, domain.ErrInvalidInput): writeError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, domain.ErrEmailAlreadyVerified): + writeError(w, http.StatusConflict, "email is already verified") case errors.Is(err, domain.ErrAPIKeyRevoked): writeError(w, http.StatusUnauthorized, "api key revoked") case errors.Is(err, domain.ErrAPIKeyExpired): diff --git a/internal/api/handlers/swagger_types.go b/internal/api/handlers/swagger_types.go index fa4b1fc..54d44d3 100644 --- a/internal/api/handlers/swagger_types.go +++ b/internal/api/handlers/swagger_types.go @@ -131,6 +131,11 @@ type ResetConfirmBody struct { Password string `json:"password" example:"N3wS3cur3P@ss!"` } +// VerifyEmailConfirmBody is the request body for POST /api/v1/auth/email/verify-confirm. +type VerifyEmailConfirmBody struct { + Token string `json:"token" example:"550e8400-e29b-41d4-a716-446655440099"` +} + // ChangePasswordBody is the request body for PUT /api/v1/auth/password/change. type ChangePasswordBody struct { CurrentPassword string `json:"current_password" example:"OldP@ss!1"` diff --git a/internal/api/router.go b/internal/api/router.go index cd794be..b53ac88 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -84,6 +84,7 @@ func NewRouter( r.Post("/refresh", authH.Refresh) r.Post("/password/reset-request", authH.RequestPasswordReset) r.Post("/password/reset-confirm", authH.ConfirmPasswordReset) + r.Post("/email/verify-confirm", authH.ConfirmEmailVerification) // Auth — requires JWT r.Group(func(r chi.Router) { @@ -91,6 +92,7 @@ func NewRouter( r.Post("/logout", authH.Logout) r.Post("/logout-all", authH.LogoutAll) r.Put("/password/change", authH.ChangePassword) + r.Post("/email/verify-request", authH.RequestEmailVerification) }) }) diff --git a/internal/config/config.go b/internal/config/config.go index 4cf6b28..6963e64 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,8 +43,9 @@ type AuthConfig struct { JWTSecret string `mapstructure:"JWT_SECRET"` AccessTokenDuration time.Duration `mapstructure:"ACCESS_TOKEN_DURATION"` RefreshTokenDuration time.Duration `mapstructure:"REFRESH_TOKEN_DURATION"` - ResetTokenDuration time.Duration `mapstructure:"RESET_TOKEN_DURATION"` - RateLimitPerMinute int `mapstructure:"RATE_LIMIT_PER_MINUTE"` + ResetTokenDuration time.Duration `mapstructure:"RESET_TOKEN_DURATION"` + VerifyEmailTokenDuration time.Duration `mapstructure:"VERIFY_EMAIL_TOKEN_DURATION"` + RateLimitPerMinute int `mapstructure:"RATE_LIMIT_PER_MINUTE"` } type EmailConfig struct { @@ -85,6 +86,7 @@ func Load() (*Config, error) { v.SetDefault("ACCESS_TOKEN_DURATION", "15m") v.SetDefault("REFRESH_TOKEN_DURATION", "168h") // 7 days v.SetDefault("RESET_TOKEN_DURATION", "1h") + v.SetDefault("VERIFY_EMAIL_TOKEN_DURATION", "24h") v.SetDefault("RATE_LIMIT_PER_MINUTE", 60) v.SetDefault("SMTP_PORT", 587) v.SetDefault("APP_BASE_URL", "http://localhost:8080") @@ -126,8 +128,9 @@ func Load() (*Config, error) { JWTSecret: v.GetString("JWT_SECRET"), AccessTokenDuration: v.GetDuration("ACCESS_TOKEN_DURATION"), RefreshTokenDuration: v.GetDuration("REFRESH_TOKEN_DURATION"), - ResetTokenDuration: v.GetDuration("RESET_TOKEN_DURATION"), - RateLimitPerMinute: v.GetInt("RATE_LIMIT_PER_MINUTE"), + ResetTokenDuration: v.GetDuration("RESET_TOKEN_DURATION"), + VerifyEmailTokenDuration: v.GetDuration("VERIFY_EMAIL_TOKEN_DURATION"), + RateLimitPerMinute: v.GetInt("RATE_LIMIT_PER_MINUTE"), } cfg.Email = EmailConfig{ Host: v.GetString("SMTP_HOST"), diff --git a/internal/domain/audit.go b/internal/domain/audit.go index 2724b16..9306f3a 100644 --- a/internal/domain/audit.go +++ b/internal/domain/audit.go @@ -22,7 +22,9 @@ const ( AuditActionAPIKeyRevoked = "apikey.revoked" AuditActionRoleCreated = "role.created" AuditActionRoleDeleted = "role.deleted" - AuditActionRoleAssigned = "role.assigned" + AuditActionRoleAssigned = "role.assigned" + AuditActionEmailVerificationRequested = "user.email_verification_requested" + AuditActionEmailVerified = "user.email_verified" ) type AuditLog struct { diff --git a/internal/domain/errors.go b/internal/domain/errors.go index ff1489b..bb8e663 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -17,5 +17,6 @@ var ( ErrAPIKeyRevoked = errors.New("api key has been revoked") ErrAPIKeyExpired = errors.New("api key has expired") ErrWeakPassword = errors.New("password does not meet requirements") - ErrInvalidInput = errors.New("invalid input") + ErrInvalidInput = errors.New("invalid input") + ErrEmailAlreadyVerified = errors.New("email is already verified") ) diff --git a/internal/repository/postgres/email_verifications.go b/internal/repository/postgres/email_verifications.go new file mode 100644 index 0000000..7c9ca77 --- /dev/null +++ b/internal/repository/postgres/email_verifications.go @@ -0,0 +1,62 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type EmailVerificationToken struct { + ID uuid.UUID + UserID uuid.UUID + TokenHash string + ExpiresAt time.Time + UsedAt *time.Time + CreatedAt time.Time +} + +func (s *Store) CreateEmailVerificationToken(ctx context.Context, userID uuid.UUID, tokenHash string, expiresAt time.Time) (*EmailVerificationToken, error) { + row := s.pool.QueryRow(ctx, + `INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + RETURNING id, user_id, token_hash, expires_at, used_at, created_at`, + userID, tokenHash, expiresAt, + ) + t := &EmailVerificationToken{} + if err := row.Scan(&t.ID, &t.UserID, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("scan email verification token: %w", err) + } + return t, nil +} + +func (s *Store) GetEmailVerificationToken(ctx context.Context, tokenHash string) (*EmailVerificationToken, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, user_id, token_hash, expires_at, used_at, created_at + FROM email_verification_tokens + WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, + tokenHash, + ) + t := &EmailVerificationToken{} + err := row.Scan(&t.ID, &t.UserID, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan email verification token: %w", err) + } + return t, nil +} + +func (s *Store) MarkEmailVerificationTokenUsed(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, `UPDATE email_verification_tokens SET used_at = now() WHERE id = $1`, id) + return err +} + +func (s *Store) DeleteEmailVerificationToken(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, `DELETE FROM email_verification_tokens WHERE id = $1`, id) + return err +} diff --git a/internal/repository/postgres/users.go b/internal/repository/postgres/users.go index 0e07d3a..ac59702 100644 --- a/internal/repository/postgres/users.go +++ b/internal/repository/postgres/users.go @@ -106,6 +106,25 @@ func (s *Store) DeactivateUser(ctx context.Context, orgID, id uuid.UUID) error { return err } +func (s *Store) GetUserByIDGlobal(ctx context.Context, id uuid.UUID) (*domain.User, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, org_id, email, hashed_password, full_name, is_active, is_superuser, email_verified_at, last_login_at, created_at, updated_at + FROM users WHERE id = $1`, id, + ) + u, err := scanUser(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return u, err +} + +func (s *Store) VerifyUserEmail(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, + `UPDATE users SET email_verified_at = now(), updated_at = now() WHERE id = $1`, id, + ) + return err +} + func scanUser(row pgx.Row) (*domain.User, error) { u := &domain.User{} var emailVerifiedAt sql.NullTime diff --git a/internal/service/auth.go b/internal/service/auth.go index d5cd35e..ab255d3 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "log/slog" "time" "unicode" @@ -108,6 +109,11 @@ func (s *AuthService) Register(ctx context.Context, in RegisterInput, ipAddress, }) s.webhookSvc.Dispatch(org.ID, domain.AuditActionUserRegistered, map[string]any{"user_id": user.ID, "email": user.Email}) + // Best-effort: send verification email, don't fail registration + if err := s.requestEmailVerificationInternal(ctx, user.ID, user.Email); err != nil { + slog.Warn("failed to send verification email on registration", "error", err, "user_id", user.ID) + } + return &RegisterOutput{Org: org, User: user}, nil } @@ -351,6 +357,88 @@ func (s *AuthService) ChangePassword(ctx context.Context, orgID, userID uuid.UUI return nil } +// RequestEmailVerification sends a verification email to the authenticated user. +func (s *AuthService) RequestEmailVerification(ctx context.Context, userID, orgID uuid.UUID) error { + user, err := s.store.GetUserByID(ctx, orgID, userID) + if err != nil { + return domain.ErrNotFound + } + if !user.IsActive { + return domain.ErrUserInactive + } + if user.EmailVerifiedAt != nil { + return domain.ErrEmailAlreadyVerified + } + + if err := s.requestEmailVerificationInternal(ctx, user.ID, user.Email); err != nil { + slog.Warn("failed to send verification email", "error", err, "user_id", userID) + } + + s.auditSvc.Log(&domain.AuditLog{ + OrgID: &orgID, + UserID: &userID, + Action: domain.AuditActionEmailVerificationRequested, + }) + s.webhookSvc.Dispatch(orgID, domain.AuditActionEmailVerificationRequested, map[string]any{"user_id": userID, "email": user.Email}) + + return nil +} + +// ConfirmEmailVerification validates the token and marks the user's email as verified. +func (s *AuthService) ConfirmEmailVerification(ctx context.Context, rawToken string) error { + tokenHash := hashString(rawToken) + evt, err := s.store.GetEmailVerificationToken(ctx, tokenHash) + if err != nil { + return fmt.Errorf("get email verification token: %w", err) + } + if evt == nil { + return domain.ErrTokenInvalid + } + + user, err := s.store.GetUserByIDGlobal(ctx, evt.UserID) + if err != nil { + return fmt.Errorf("get user for email verification: %w", err) + } + + if err := s.store.VerifyUserEmail(ctx, evt.UserID); err != nil { + return fmt.Errorf("verify user email: %w", err) + } + if err := s.store.MarkEmailVerificationTokenUsed(ctx, evt.ID); err != nil { + return fmt.Errorf("mark verification token used: %w", err) + } + + s.auditSvc.Log(&domain.AuditLog{ + OrgID: &user.OrgID, + UserID: &user.ID, + Action: domain.AuditActionEmailVerified, + }) + s.webhookSvc.Dispatch(user.OrgID, domain.AuditActionEmailVerified, map[string]any{"user_id": user.ID, "email": user.Email}) + + return nil +} + +// requestEmailVerificationInternal generates a verification token, stores it, and sends the email. +func (s *AuthService) requestEmailVerificationInternal(ctx context.Context, userID uuid.UUID, email string) error { + rawToken := uuid.New().String() + tokenHash := hashString(rawToken) + expiresAt := time.Now().Add(s.authCfg.VerifyEmailTokenDuration) + + record, err := s.store.CreateEmailVerificationToken(ctx, userID, tokenHash, expiresAt) + if err != nil { + return fmt.Errorf("create email verification token: %w", err) + } + + if err := s.emailSvc.SendEmailVerification(email, rawToken); err != nil { + if cleanupErr := s.store.DeleteEmailVerificationToken(ctx, record.ID); cleanupErr != nil { + slog.Error("email verification token cleanup failed after email delivery failure", "error", cleanupErr) + return fmt.Errorf("cleanup verification token after email failure: %w", cleanupErr) + } + return err + } + + return nil +} + // blacklistAccessToken stores the token JTI in Redis so that all instances // reject it immediately. Failures are surfaced so callers can abort before // mutating DB-backed auth state when revocation guarantees cannot be enforced. diff --git a/internal/service/auth_verify_test.go b/internal/service/auth_verify_test.go new file mode 100644 index 0000000..789edfe --- /dev/null +++ b/internal/service/auth_verify_test.go @@ -0,0 +1,239 @@ +package service + +import ( + "context" + "errors" + "net/smtp" + "regexp" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/osama1998h/uniauth/internal/config" + "github.com/osama1998h/uniauth/internal/domain" + db "github.com/osama1998h/uniauth/internal/repository/postgres" + "github.com/osama1998h/uniauth/internal/testutil" +) + +func TestAuthServiceRequestEmailVerificationCleansUpOnDeliveryFailure(t *testing.T) { + store := testutil.RequireTestStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + org := testutil.CreateOrganization(t, store, "email-verify-failure-org") + user := testutil.CreateUser(t, store, org.ID, "email-verify-failure-user") + + tests := []struct { + name string + emailSvc *EmailService + }{ + { + name: "smtp not configured", + emailSvc: NewEmailService(config.EmailConfig{ + BaseURL: "https://app.example.com", + }, testutil.DiscardLogger(), false), + }, + { + name: "smtp send failure", + emailSvc: func() *EmailService { + svc := NewEmailService(config.EmailConfig{ + Host: "smtp.example.com", + Port: 587, + From: "noreply@example.com", + BaseURL: "https://app.example.com", + }, testutil.DiscardLogger(), false) + svc.sendMail = func(string, smtp.Auth, string, []string, []byte) error { + return errors.New("smtp unavailable") + } + return svc + }(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc := &AuthService{ + store: store, + emailSvc: tc.emailSvc, + authCfg: config.AuthConfig{VerifyEmailTokenDuration: 24 * time.Hour}, + } + + err := svc.requestEmailVerificationInternal(ctx, user.ID, user.Email) + if err == nil { + t.Fatal("expected error from email delivery failure") + } + + if got := countEmailVerificationTokensForUser(t, ctx, store, user.ID); got != 0 { + t.Fatalf("expected no verification tokens after cleanup, got %d", got) + } + }) + } +} + +func TestAuthServiceRequestEmailVerificationSkipsAlreadyVerified(t *testing.T) { + store := testutil.RequireTestStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + org := testutil.CreateOrganization(t, store, "email-verify-already-org") + user := testutil.CreateUser(t, store, org.ID, "email-verify-already-user") + + // Mark user as verified + if err := store.VerifyUserEmail(ctx, user.ID); err != nil { + t.Fatalf("VerifyUserEmail() error: %v", err) + } + + svc := &AuthService{ + store: store, + auditSvc: NewAuditService(store, testutil.DiscardLogger()), + webhookSvc: NewWebhookService(store, testutil.DiscardLogger()), + emailSvc: NewEmailService(config.EmailConfig{ + BaseURL: "https://app.example.com", + }, testutil.DiscardLogger(), false), + authCfg: config.AuthConfig{VerifyEmailTokenDuration: 24 * time.Hour}, + } + + err := svc.RequestEmailVerification(ctx, user.ID, org.ID) + if !errors.Is(err, domain.ErrEmailAlreadyVerified) { + t.Fatalf("expected ErrEmailAlreadyVerified, got %v", err) + } +} + +func TestAuthServiceRequestEmailVerificationPersistsTokenOnSuccess(t *testing.T) { + store := testutil.RequireTestStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + org := testutil.CreateOrganization(t, store, "email-verify-success-org") + user := testutil.CreateUser(t, store, org.ID, "email-verify-success-user") + + emailSvc := NewEmailService(config.EmailConfig{ + Host: "smtp.example.com", + Port: 587, + From: "noreply@example.com", + BaseURL: "https://app.example.com", + }, testutil.DiscardLogger(), false) + + var deliveredBody []byte + emailSvc.sendMail = func(_ string, _ smtp.Auth, _ string, _ []string, msg []byte) error { + deliveredBody = append([]byte(nil), msg...) + return nil + } + + svc := &AuthService{ + store: store, + auditSvc: NewAuditService(store, testutil.DiscardLogger()), + webhookSvc: NewWebhookService(store, testutil.DiscardLogger()), + emailSvc: emailSvc, + authCfg: config.AuthConfig{VerifyEmailTokenDuration: 24 * time.Hour}, + } + + if err := svc.RequestEmailVerification(ctx, user.ID, org.ID); err != nil { + t.Fatalf("RequestEmailVerification() unexpected error: %v", err) + } + + if got := countEmailVerificationTokensForUser(t, ctx, store, user.ID); got != 1 { + t.Fatalf("expected one verification token after successful delivery, got %d", got) + } + + rawToken := extractVerificationToken(t, string(deliveredBody)) + tokenRecord, err := store.GetEmailVerificationToken(ctx, hashString(rawToken)) + if err != nil { + t.Fatalf("GetEmailVerificationToken() error: %v", err) + } + if tokenRecord == nil { + t.Fatal("expected active email verification token record") + } +} + +func TestAuthServiceConfirmEmailVerification(t *testing.T) { + store := testutil.RequireTestStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + org := testutil.CreateOrganization(t, store, "email-verify-confirm-org") + user := testutil.CreateUser(t, store, org.ID, "email-verify-confirm-user") + + // Create a verification token directly + rawToken := uuid.New().String() + tokenHash := hashString(rawToken) + expiresAt := time.Now().Add(24 * time.Hour) + + _, err := store.CreateEmailVerificationToken(ctx, user.ID, tokenHash, expiresAt) + if err != nil { + t.Fatalf("CreateEmailVerificationToken() error: %v", err) + } + + svc := &AuthService{ + store: store, + auditSvc: NewAuditService(store, testutil.DiscardLogger()), + webhookSvc: NewWebhookService(store, testutil.DiscardLogger()), + authCfg: config.AuthConfig{VerifyEmailTokenDuration: 24 * time.Hour}, + } + + if err := svc.ConfirmEmailVerification(ctx, rawToken); err != nil { + t.Fatalf("ConfirmEmailVerification() unexpected error: %v", err) + } + + // Verify user's email_verified_at is set + updatedUser, err := store.GetUserByID(ctx, org.ID, user.ID) + if err != nil { + t.Fatalf("GetUserByID() error: %v", err) + } + if updatedUser.EmailVerifiedAt == nil { + t.Fatal("expected email_verified_at to be set after confirmation") + } + + // Verify token is marked as used (second use should fail) + tokenRecord, err := store.GetEmailVerificationToken(ctx, tokenHash) + if err != nil { + t.Fatalf("GetEmailVerificationToken() error: %v", err) + } + if tokenRecord != nil { + t.Fatal("expected token to be consumed (not returned by query)") + } +} + +func TestAuthServiceConfirmEmailVerificationInvalidToken(t *testing.T) { + store := testutil.RequireTestStore(t) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + svc := &AuthService{ + store: store, + auditSvc: NewAuditService(store, testutil.DiscardLogger()), + webhookSvc: NewWebhookService(store, testutil.DiscardLogger()), + authCfg: config.AuthConfig{VerifyEmailTokenDuration: 24 * time.Hour}, + } + + err := svc.ConfirmEmailVerification(ctx, "garbage-token-value") + if !errors.Is(err, domain.ErrTokenInvalid) { + t.Fatalf("expected ErrTokenInvalid, got %v", err) + } +} + +func countEmailVerificationTokensForUser(t *testing.T, ctx context.Context, store *db.Store, userID uuid.UUID) int { + t.Helper() + + var count int + if err := store.Pool().QueryRow(ctx, `SELECT COUNT(*) FROM email_verification_tokens WHERE user_id = $1`, userID).Scan(&count); err != nil { + t.Fatalf("count email verification tokens: %v", err) + } + return count +} + +func extractVerificationToken(t *testing.T, body string) string { + t.Helper() + + matches := regexp.MustCompile(`token=([0-9a-fA-F-]+)`).FindStringSubmatch(body) + if len(matches) != 2 { + t.Fatalf("verification token not found in email body: %q", body) + } + return matches[1] +} diff --git a/internal/service/email.go b/internal/service/email.go index 0591296..3c414e5 100644 --- a/internal/service/email.go +++ b/internal/service/email.go @@ -57,3 +57,27 @@ func (e *EmailService) SendPasswordReset(toEmail, resetToken string) error { return nil } + +// SendEmailVerification emails a verification link to the given address. +func (e *EmailService) SendEmailVerification(toEmail, verificationToken string) error { + if e.cfg.Host == "" { + if e.isDevelopment { + e.logger.Info("email verification email suppressed because SMTP is not configured") + } else { + e.logger.Warn("email verification email unavailable because SMTP is not configured") + } + return errEmailDeliveryUnavailable + } + + link := fmt.Sprintf("%s/verify-email?token=%s", e.cfg.BaseURL, verificationToken) + body := fmt.Sprintf("Subject: Verify Your Email Address\r\n\r\nClick the link to verify your email address:\r\n%s\r\n\r\nThis link expires in 24 hours.", link) + + auth := smtp.PlainAuth("", e.cfg.Username, e.cfg.Password, e.cfg.Host) + addr := fmt.Sprintf("%s:%d", e.cfg.Host, e.cfg.Port) + if err := e.sendMail(addr, auth, e.cfg.From, []string{toEmail}, []byte(body)); err != nil { + e.logger.Error("email verification email delivery failed", "error", err) + return fmt.Errorf("%w: %v", errEmailDeliveryUnavailable, err) + } + + return nil +} diff --git a/migrations/000004_email_verification_tokens.down.sql b/migrations/000004_email_verification_tokens.down.sql new file mode 100644 index 0000000..41b6747 --- /dev/null +++ b/migrations/000004_email_verification_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS email_verification_tokens; diff --git a/migrations/000004_email_verification_tokens.up.sql b/migrations/000004_email_verification_tokens.up.sql new file mode 100644 index 0000000..8161ce8 --- /dev/null +++ b/migrations/000004_email_verification_tokens.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/sql/queries/email_verifications.sql b/sql/queries/email_verifications.sql new file mode 100644 index 0000000..58045d0 --- /dev/null +++ b/sql/queries/email_verifications.sql @@ -0,0 +1,18 @@ +-- name: CreateEmailVerificationToken :one +INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetEmailVerificationToken :one +SELECT * FROM email_verification_tokens +WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() +LIMIT 1; + +-- name: MarkEmailVerificationTokenUsed :exec +UPDATE email_verification_tokens SET used_at = now() WHERE id = $1; + +-- name: DeleteEmailVerificationToken :exec +DELETE FROM email_verification_tokens WHERE id = $1; + +-- name: DeleteExpiredEmailVerificationTokens :exec +DELETE FROM email_verification_tokens WHERE expires_at < now(); diff --git a/sql/queries/users.sql b/sql/queries/users.sql index 9494cb8..e7c6232 100644 --- a/sql/queries/users.sql +++ b/sql/queries/users.sql @@ -35,5 +35,8 @@ UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1; -- name: DeactivateUser :exec UPDATE users SET is_active = false, updated_at = now() WHERE id = $1; +-- name: GetUserByIDGlobal :one +SELECT * FROM users WHERE id = $1 LIMIT 1; + -- name: VerifyUserEmail :exec UPDATE users SET email_verified_at = now(), updated_at = now() WHERE id = $1; diff --git a/sql/schema.sql b/sql/schema.sql index 2ffa45e..a87e6a2 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -54,6 +54,16 @@ CREATE TABLE password_reset_tokens ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- Email verification tokens +CREATE TABLE email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + -- Roles CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(),