From 5c3f59ce7865e819744c1f2057cc1f114635a477 Mon Sep 17 00:00:00 2001 From: madeindreams Date: Tue, 23 Dec 2025 09:23:29 -0800 Subject: [PATCH] newslatter subscription --- DEVELOP.md | 8 + README.md | 2 + cmd/quantum-auth/main.go | 3 +- docs/docs.go | 257 ++++++++++++++++-- docs/swagger.json | 256 +++++++++++++++-- docs/swagger.yaml | 189 +++++++++++-- go.mod | 1 + go.sum | 2 + .../00004_create_news_letter.up.sql | 35 +++ .../00004_drop_news_letter.down.sql | 1 + internal/quantum/database/newsletter.go | 131 +++++++++ internal/quantum/email/template_welcome.go | 172 ++++++++---- internal/quantum/http/dto.go | 10 + internal/quantum/http/handlers.go | 79 +++++- internal/quantum/http/http.go | 33 ++- internal/quantum/setup.go | 2 - 16 files changed, 1043 insertions(+), 138 deletions(-) create mode 100644 DEVELOP.md create mode 100644 internal/quantum/database/migrations/00004_create_news_letter.up.sql create mode 100644 internal/quantum/database/migrations/00004_drop_news_letter.down.sql create mode 100644 internal/quantum/database/newsletter.go diff --git a/DEVELOP.md b/DEVELOP.md new file mode 100644 index 0000000..3a63997 --- /dev/null +++ b/DEVELOP.md @@ -0,0 +1,8 @@ +# Develop + + +## Swaggo + +```bash +swag init -g cmd/quantum-auth/main.go +``` \ No newline at end of file diff --git a/README.md b/README.md index 15986ee..bd4ea4b 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,5 @@ If you rely on QuantumAuth or believe in our mission, please consider becoming a This section will list the names or logos of organizations and individuals who sponsor QuantumAuth at the *Project Sponsor* tier and above. If you'd like to be featured here, please visit our Sponsor page! + + diff --git a/cmd/quantum-auth/main.go b/cmd/quantum-auth/main.go index 3f9f024..f7d7b2f 100644 --- a/cmd/quantum-auth/main.go +++ b/cmd/quantum-auth/main.go @@ -11,10 +11,9 @@ import ( "github.com/quantumauth-io/quantum-go-utils/config" ) -// @title Quantum Auth API +// @title QuantumAuth API // @version 1.0 // @description Experimental quantum-resistant, hardware-aware auth service. -// @host localhost:1042 // @BasePath / func main() { diff --git a/docs/docs.go b/docs/docs.go index 6de6c2d..4dd85cd 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -29,7 +29,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/quantumhttp.SecurePingResponse" + "$ref": "#/definitions/http.SecurePingResponse" } }, "401": { @@ -61,7 +61,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/quantumhttp.authChallengeRequest" + "$ref": "#/definitions/http.authChallengeRequest" } } ], @@ -69,7 +69,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/quantumhttp.authChallengeResponse" + "$ref": "#/definitions/http.authChallengeResponse" } }, "400": { @@ -87,9 +87,61 @@ const docTemplate = `{ } } }, + "/auth/full-login": { + "post": { + "description": "Authenticates a device by verifying the user's password and both TPM \u0026 PQ signatures over a login message.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Full device login (password + TPM + PQ)", + "parameters": [ + { + "description": "Full login request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.fullLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.fullLoginResponse" + } + }, + "400": { + "description": "invalid input", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "404": { + "description": "user or device not found", + "schema": { + "type": "string" + } + } + } + } + }, "/auth/verify": { "post": { - "description": "Verifies a signed challenge response using TPM and PQ signatures plus password", + "description": "Verifies TPM + PQ signatures in the Authorization header for an API request", "consumes": [ "application/json" ], @@ -99,7 +151,7 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Verify auth response (hybrid TPM + PQ)", + "summary": "Verify signed QuantumAuth request", "parameters": [ { "description": "Verify request", @@ -107,7 +159,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/quantumhttp.authVerifyRequest" + "$ref": "#/definitions/http.authVerifyRequest" } } ], @@ -115,7 +167,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/quantumhttp.authVerifyResponse" + "$ref": "#/definitions/http.authVerifyResponse" } }, "400": { @@ -125,13 +177,13 @@ const docTemplate = `{ } }, "401": { - "description": "invalid signature or password", + "description": "unauthorized", "schema": { - "$ref": "#/definitions/quantumhttp.authVerifyResponse" + "$ref": "#/definitions/http.authVerifyResponse" } }, "404": { - "description": "challenge or device not found", + "description": "user or device not found", "schema": { "type": "string" } @@ -159,7 +211,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/quantumhttp.registerDeviceRequest" + "$ref": "#/definitions/http.registerDeviceRequest" } } ], @@ -167,7 +219,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/quantumhttp.registerDeviceResponse" + "$ref": "#/definitions/http.registerDeviceResponse" } }, "400": { @@ -185,6 +237,110 @@ const docTemplate = `{ } } }, + "/newsletter/subscribe": { + "post": { + "description": "Creates or re-subscribes an email to the newsletter", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "newsletter" + ], + "summary": "Subscribe to newsletter", + "parameters": [ + { + "description": "Newsletter subscribe payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.newsletterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/http.newsletterResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/newsletter/unsubscribe": { + "post": { + "description": "Marks an email as unsubscribed (soft unsubscribe)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "newsletter" + ], + "summary": "Unsubscribe from newsletter", + "parameters": [ + { + "description": "Newsletter unsubscribe payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.newsletterRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.newsletterResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/users/register": { "post": { "description": "Create a user account", @@ -205,7 +361,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/quantumhttp.SignupRequest" + "$ref": "#/definitions/http.SignupRequest" } } ], @@ -233,7 +389,7 @@ const docTemplate = `{ } }, "definitions": { - "quantumhttp.SecurePingResponse": { + "http.SecurePingResponse": { "type": "object", "properties": { "device_id": { @@ -250,7 +406,7 @@ const docTemplate = `{ } } }, - "quantumhttp.SignupRequest": { + "http.SignupRequest": { "type": "object", "required": [ "email" @@ -265,7 +421,7 @@ const docTemplate = `{ "lastName": { "type": "string" }, - "password": { + "password_b64": { "type": "string" }, "username": { @@ -273,7 +429,7 @@ const docTemplate = `{ } } }, - "quantumhttp.authChallengeRequest": { + "http.authChallengeRequest": { "type": "object", "properties": { "device_id": { @@ -281,7 +437,7 @@ const docTemplate = `{ } } }, - "quantumhttp.authChallengeResponse": { + "http.authChallengeResponse": { "type": "object", "properties": { "challenge_id": { @@ -291,20 +447,34 @@ const docTemplate = `{ "type": "string" }, "nonce": { + "type": "integer" + } + } + }, + "http.authVerifyRequest": { + "type": "object" + }, + "http.authVerifyResponse": { + "type": "object", + "properties": { + "authenticated": { + "type": "boolean" + }, + "user_id": { "type": "string" } } }, - "quantumhttp.authVerifyRequest": { + "http.fullLoginRequest": { "type": "object", "properties": { - "challenge_id": { + "device_id": { "type": "string" }, - "device_id": { + "message_b64": { "type": "string" }, - "password": { + "password_b64": { "type": "string" }, "pq_signature": { @@ -312,21 +482,52 @@ const docTemplate = `{ }, "tpm_signature": { "type": "string" + }, + "user_id": { + "type": "string" } } }, - "quantumhttp.authVerifyResponse": { + "http.fullLoginResponse": { "type": "object", "properties": { "authenticated": { "type": "boolean" }, + "device_id": { + "type": "string" + }, "user_id": { "type": "string" } } }, - "quantumhttp.registerDeviceRequest": { + "http.newsletterRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "http.newsletterResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "newsletter_id": { + "type": "string" + }, + "subscribed": { + "type": "boolean" + } + } + }, + "http.registerDeviceRequest": { "type": "object", "properties": { "device_label": { @@ -338,12 +539,12 @@ const docTemplate = `{ "tpm_public_key": { "type": "string" }, - "user_email": { + "user_Id": { "type": "string" } } }, - "quantumhttp.registerDeviceResponse": { + "http.registerDeviceResponse": { "type": "object", "properties": { "device_id": { @@ -357,10 +558,10 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ Version: "1.0", - Host: "localhost:1042", + Host: "", BasePath: "/", Schemes: []string{}, - Title: "Quantum Auth API", + Title: "QuantumAuth API", Description: "Experimental quantum-resistant, hardware-aware auth service.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, diff --git a/docs/swagger.json b/docs/swagger.json index 65f3671..539e71e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2,11 +2,10 @@ "swagger": "2.0", "info": { "description": "Experimental quantum-resistant, hardware-aware auth service.", - "title": "Quantum Auth API", + "title": "QuantumAuth API", "contact": {}, "version": "1.0" }, - "host": "localhost:1042", "basePath": "/", "paths": { "/api/secure-ping": { @@ -23,7 +22,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/quantumhttp.SecurePingResponse" + "$ref": "#/definitions/http.SecurePingResponse" } }, "401": { @@ -55,7 +54,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/quantumhttp.authChallengeRequest" + "$ref": "#/definitions/http.authChallengeRequest" } } ], @@ -63,7 +62,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/quantumhttp.authChallengeResponse" + "$ref": "#/definitions/http.authChallengeResponse" } }, "400": { @@ -81,9 +80,61 @@ } } }, + "/auth/full-login": { + "post": { + "description": "Authenticates a device by verifying the user's password and both TPM \u0026 PQ signatures over a login message.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Full device login (password + TPM + PQ)", + "parameters": [ + { + "description": "Full login request", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.fullLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.fullLoginResponse" + } + }, + "400": { + "description": "invalid input", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "404": { + "description": "user or device not found", + "schema": { + "type": "string" + } + } + } + } + }, "/auth/verify": { "post": { - "description": "Verifies a signed challenge response using TPM and PQ signatures plus password", + "description": "Verifies TPM + PQ signatures in the Authorization header for an API request", "consumes": [ "application/json" ], @@ -93,7 +144,7 @@ "tags": [ "auth" ], - "summary": "Verify auth response (hybrid TPM + PQ)", + "summary": "Verify signed QuantumAuth request", "parameters": [ { "description": "Verify request", @@ -101,7 +152,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/quantumhttp.authVerifyRequest" + "$ref": "#/definitions/http.authVerifyRequest" } } ], @@ -109,7 +160,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/quantumhttp.authVerifyResponse" + "$ref": "#/definitions/http.authVerifyResponse" } }, "400": { @@ -119,13 +170,13 @@ } }, "401": { - "description": "invalid signature or password", + "description": "unauthorized", "schema": { - "$ref": "#/definitions/quantumhttp.authVerifyResponse" + "$ref": "#/definitions/http.authVerifyResponse" } }, "404": { - "description": "challenge or device not found", + "description": "user or device not found", "schema": { "type": "string" } @@ -153,7 +204,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/quantumhttp.registerDeviceRequest" + "$ref": "#/definitions/http.registerDeviceRequest" } } ], @@ -161,7 +212,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/quantumhttp.registerDeviceResponse" + "$ref": "#/definitions/http.registerDeviceResponse" } }, "400": { @@ -179,6 +230,110 @@ } } }, + "/newsletter/subscribe": { + "post": { + "description": "Creates or re-subscribes an email to the newsletter", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "newsletter" + ], + "summary": "Subscribe to newsletter", + "parameters": [ + { + "description": "Newsletter subscribe payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.newsletterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/http.newsletterResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/newsletter/unsubscribe": { + "post": { + "description": "Marks an email as unsubscribed (soft unsubscribe)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "newsletter" + ], + "summary": "Unsubscribe from newsletter", + "parameters": [ + { + "description": "Newsletter unsubscribe payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/http.newsletterRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.newsletterResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/users/register": { "post": { "description": "Create a user account", @@ -199,7 +354,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/quantumhttp.SignupRequest" + "$ref": "#/definitions/http.SignupRequest" } } ], @@ -227,7 +382,7 @@ } }, "definitions": { - "quantumhttp.SecurePingResponse": { + "http.SecurePingResponse": { "type": "object", "properties": { "device_id": { @@ -244,7 +399,7 @@ } } }, - "quantumhttp.SignupRequest": { + "http.SignupRequest": { "type": "object", "required": [ "email" @@ -259,7 +414,7 @@ "lastName": { "type": "string" }, - "password": { + "password_b64": { "type": "string" }, "username": { @@ -267,7 +422,7 @@ } } }, - "quantumhttp.authChallengeRequest": { + "http.authChallengeRequest": { "type": "object", "properties": { "device_id": { @@ -275,7 +430,7 @@ } } }, - "quantumhttp.authChallengeResponse": { + "http.authChallengeResponse": { "type": "object", "properties": { "challenge_id": { @@ -285,20 +440,34 @@ "type": "string" }, "nonce": { + "type": "integer" + } + } + }, + "http.authVerifyRequest": { + "type": "object" + }, + "http.authVerifyResponse": { + "type": "object", + "properties": { + "authenticated": { + "type": "boolean" + }, + "user_id": { "type": "string" } } }, - "quantumhttp.authVerifyRequest": { + "http.fullLoginRequest": { "type": "object", "properties": { - "challenge_id": { + "device_id": { "type": "string" }, - "device_id": { + "message_b64": { "type": "string" }, - "password": { + "password_b64": { "type": "string" }, "pq_signature": { @@ -306,21 +475,52 @@ }, "tpm_signature": { "type": "string" + }, + "user_id": { + "type": "string" } } }, - "quantumhttp.authVerifyResponse": { + "http.fullLoginResponse": { "type": "object", "properties": { "authenticated": { "type": "boolean" }, + "device_id": { + "type": "string" + }, "user_id": { "type": "string" } } }, - "quantumhttp.registerDeviceRequest": { + "http.newsletterRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "http.newsletterResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "newsletter_id": { + "type": "string" + }, + "subscribed": { + "type": "boolean" + } + } + }, + "http.registerDeviceRequest": { "type": "object", "properties": { "device_label": { @@ -332,12 +532,12 @@ "tpm_public_key": { "type": "string" }, - "user_email": { + "user_Id": { "type": "string" } } }, - "quantumhttp.registerDeviceResponse": { + "http.registerDeviceResponse": { "type": "object", "properties": { "device_id": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9314d06..5067f33 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,6 +1,6 @@ basePath: / definitions: - quantumhttp.SecurePingResponse: + http.SecurePingResponse: properties: device_id: type: string @@ -11,7 +11,7 @@ definitions: user_id: type: string type: object - quantumhttp.SignupRequest: + http.SignupRequest: properties: email: type: string @@ -19,48 +19,77 @@ definitions: type: string lastName: type: string - password: + password_b64: type: string username: type: string required: - email type: object - quantumhttp.authChallengeRequest: + http.authChallengeRequest: properties: device_id: type: string type: object - quantumhttp.authChallengeResponse: + http.authChallengeResponse: properties: challenge_id: type: string expires_at: type: string nonce: - type: string + type: integer + type: object + http.authVerifyRequest: type: object - quantumhttp.authVerifyRequest: + http.authVerifyResponse: properties: - challenge_id: + authenticated: + type: boolean + user_id: type: string + type: object + http.fullLoginRequest: + properties: device_id: type: string - password: + message_b64: + type: string + password_b64: type: string pq_signature: type: string tpm_signature: type: string + user_id: + type: string type: object - quantumhttp.authVerifyResponse: + http.fullLoginResponse: properties: authenticated: type: boolean + device_id: + type: string user_id: type: string type: object - quantumhttp.registerDeviceRequest: + http.newsletterRequest: + properties: + email: + type: string + required: + - email + type: object + http.newsletterResponse: + properties: + email: + type: string + newsletter_id: + type: string + subscribed: + type: boolean + type: object + http.registerDeviceRequest: properties: device_label: type: string @@ -68,19 +97,18 @@ definitions: type: string tpm_public_key: type: string - user_email: + user_Id: type: string type: object - quantumhttp.registerDeviceResponse: + http.registerDeviceResponse: properties: device_id: type: string type: object -host: localhost:1042 info: contact: {} description: Experimental quantum-resistant, hardware-aware auth service. - title: Quantum Auth API + title: QuantumAuth API version: "1.0" paths: /api/secure-ping: @@ -92,7 +120,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/quantumhttp.SecurePingResponse' + $ref: '#/definitions/http.SecurePingResponse' "401": description: unauthorized schema: @@ -111,14 +139,14 @@ paths: name: payload required: true schema: - $ref: '#/definitions/quantumhttp.authChallengeRequest' + $ref: '#/definitions/http.authChallengeRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/quantumhttp.authChallengeResponse' + $ref: '#/definitions/http.authChallengeResponse' "400": description: invalid input schema: @@ -130,39 +158,74 @@ paths: summary: Issue auth challenge tags: - auth + /auth/full-login: + post: + consumes: + - application/json + description: Authenticates a device by verifying the user's password and both + TPM & PQ signatures over a login message. + parameters: + - description: Full login request + in: body + name: payload + required: true + schema: + $ref: '#/definitions/http.fullLoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.fullLoginResponse' + "400": + description: invalid input + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "404": + description: user or device not found + schema: + type: string + summary: Full device login (password + TPM + PQ) + tags: + - auth /auth/verify: post: consumes: - application/json - description: Verifies a signed challenge response using TPM and PQ signatures - plus password + description: Verifies TPM + PQ signatures in the Authorization header for an + API request parameters: - description: Verify request in: body name: payload required: true schema: - $ref: '#/definitions/quantumhttp.authVerifyRequest' + $ref: '#/definitions/http.authVerifyRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/quantumhttp.authVerifyResponse' + $ref: '#/definitions/http.authVerifyResponse' "400": description: invalid input schema: type: string "401": - description: invalid signature or password + description: unauthorized schema: - $ref: '#/definitions/quantumhttp.authVerifyResponse' + $ref: '#/definitions/http.authVerifyResponse' "404": - description: challenge or device not found + description: user or device not found schema: type: string - summary: Verify auth response (hybrid TPM + PQ) + summary: Verify signed QuantumAuth request tags: - auth /devices/register: @@ -177,14 +240,14 @@ paths: name: payload required: true schema: - $ref: '#/definitions/quantumhttp.registerDeviceRequest' + $ref: '#/definitions/http.registerDeviceRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/quantumhttp.registerDeviceResponse' + $ref: '#/definitions/http.registerDeviceResponse' "400": description: invalid input schema: @@ -196,6 +259,74 @@ paths: summary: Register device tags: - devices + /newsletter/subscribe: + post: + consumes: + - application/json + description: Creates or re-subscribes an email to the newsletter + parameters: + - description: Newsletter subscribe payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/http.newsletterRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/http.newsletterResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Subscribe to newsletter + tags: + - newsletter + /newsletter/unsubscribe: + post: + consumes: + - application/json + description: Marks an email as unsubscribed (soft unsubscribe) + parameters: + - description: Newsletter unsubscribe payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/http.newsletterRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.newsletterResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Unsubscribe from newsletter + tags: + - newsletter /users/register: post: consumes: @@ -207,7 +338,7 @@ paths: name: payload required: true schema: - $ref: '#/definitions/quantumhttp.SignupRequest' + $ref: '#/definitions/http.SignupRequest' produces: - application/json responses: diff --git a/go.mod b/go.mod index 1f18995..20f5efb 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect + github.com/gin-contrib/cors v1.7.6 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect diff --git a/go.sum b/go.sum index 135063f..b28edab 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= diff --git a/internal/quantum/database/migrations/00004_create_news_letter.up.sql b/internal/quantum/database/migrations/00004_create_news_letter.up.sql new file mode 100644 index 0000000..f873caa --- /dev/null +++ b/internal/quantum/database/migrations/00004_create_news_letter.up.sql @@ -0,0 +1,35 @@ +-- Create newsletter table +CREATE TABLE IF NOT EXISTS newsletter ( + newsletter_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + email VARCHAR(255) NOT NULL UNIQUE, + subscribed BOOLEAN NOT NULL DEFAULT true, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Create reusable updated_at trigger function (idempotent) +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Attach trigger to newsletter table (idempotent) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_trigger + WHERE tgname = 'newsletter_set_updated_at' + ) THEN + CREATE TRIGGER newsletter_set_updated_at + BEFORE UPDATE ON newsletter + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + END IF; +END; +$$; diff --git a/internal/quantum/database/migrations/00004_drop_news_letter.down.sql b/internal/quantum/database/migrations/00004_drop_news_letter.down.sql new file mode 100644 index 0000000..b99e349 --- /dev/null +++ b/internal/quantum/database/migrations/00004_drop_news_letter.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS newsletter; diff --git a/internal/quantum/database/newsletter.go b/internal/quantum/database/newsletter.go new file mode 100644 index 0000000..e29651c --- /dev/null +++ b/internal/quantum/database/newsletter.go @@ -0,0 +1,131 @@ +package database + +import ( + "context" + "time" + + "github.com/quantumauth-io/quantum-go-utils/log" +) + +// NewsletterSubscription represents a row in the newsletter table. +type NewsletterSubscription struct { + ID string + Email string + Subscribed bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type SubscribeNewsletterInput struct { + Email string +} + +// SubscribeNewsletter creates or re-subscribes an email. +// - If email is new: inserts a row subscribed=true +// - If email exists: sets subscribed=true (re-subscribe) and bumps updated_at via trigger +func (r *QuantumAuthRepository) SubscribeNewsletter(ctx context.Context, in SubscribeNewsletterInput) (string, error) { + const query = ` + INSERT INTO newsletter (email, subscribed) + VALUES ($1, true) + ON CONFLICT (email) + DO UPDATE SET subscribed = EXCLUDED.subscribed + RETURNING newsletter_id; + ` + + var id string + row, err := r.db.QueryRow(ctx, query, in.Email) + if err != nil { + log.Error("Error subscribing newsletter", "error", err, "email", in.Email) + return "", err + } + + if err := row.Scan(&id); err != nil { + log.Error("Error subscribing newsletter (scan)", "error", err, "email", in.Email) + return "", err + } + + return id, nil +} + +type UnsubscribeNewsletterInput struct { + Email string +} + +// UnsubscribeNewsletter sets subscribed=false for the given email. +// If the email doesn't exist, it returns nil (no row found) as an error from Scan. +func (r *QuantumAuthRepository) UnsubscribeNewsletter(ctx context.Context, in UnsubscribeNewsletterInput) (string, error) { + const query = ` + UPDATE newsletter + SET subscribed = false + WHERE email = $1 + RETURNING newsletter_id; + ` + + var id string + row, err := r.db.QueryRow(ctx, query, in.Email) + if err != nil { + log.Error("Error unsubscribing newsletter", "error", err, "email", in.Email) + return "", err + } + + if err := row.Scan(&id); err != nil { + log.Error("Error unsubscribing newsletter (scan)", "error", err, "email", in.Email) + return "", err + } + + return id, nil +} + +// GetNewsletterByEmail returns a newsletter subscription row by email. +func (r *QuantumAuthRepository) GetNewsletterByEmail(ctx context.Context, email string) (*NewsletterSubscription, error) { + const query = ` + SELECT newsletter_id, email, subscribed, created_at, updated_at + FROM newsletter + WHERE email = $1; + ` + + var n NewsletterSubscription + row, err := r.db.QueryRow(ctx, query, email) + if err != nil { + log.Error("Error getting newsletter subscription", "error", err, "email", email) + return nil, err + } + + if err := row.Scan( + &n.ID, + &n.Email, + &n.Subscribed, + &n.CreatedAt, + &n.UpdatedAt, + ); err != nil { + log.Error("Error getting newsletter subscription (scan)", "error", err, "email", email) + return nil, err + } + + return &n, nil +} + +// IsNewsletterSubscribed is a small helper to check if an email is subscribed. +// Returns (false, nil) if no row exists. +func (r *QuantumAuthRepository) IsNewsletterSubscribed(ctx context.Context, email string) (bool, error) { + const query = ` + SELECT subscribed + FROM newsletter + WHERE email = $1; + ` + + var subscribed bool + row, err := r.db.QueryRow(ctx, query, email) + if err != nil { + // You likely want to treat "no rows" as not subscribed at call site. + log.Error("Error checking newsletter subscription", "error", err, "email", email) + return false, err + } + + if err := row.Scan(&subscribed); err != nil { + log.Error("Error checking newsletter subscription (scan)", "error", err, "email", email) + return false, err + } + + return subscribed, nil +} diff --git a/internal/quantum/email/template_welcome.go b/internal/quantum/email/template_welcome.go index 488c622..1f80889 100644 --- a/internal/quantum/email/template_welcome.go +++ b/internal/quantum/email/template_welcome.go @@ -7,87 +7,168 @@ import ( "time" ) -// WelcomeEmailHTML returns a simple, nice-looking HTML email. -// Keep it table-based for best compatibility across email clients. -func WelcomeEmailHTML(username, docsURL string) string { +type emailTheme struct { + Primary500 string // sky-500 + Primary600 string // sky-600-ish + Accent500 string // blue-500 + Bg950 string // deep background + Bg900 string + Surface string // rgba white overlay + Border string // rgba border + Text string // near-white + Muted string + Subtle string +} + +func qaEmailTheme() emailTheme { + return emailTheme{ + Primary600: "#0284C7", + Primary500: "#0EA5E9", + Accent500: "#3B82F6", + + Bg950: "#030712", + Bg900: "#081022", + + // Use solid colors for email (no rgba) + Surface: "#0B1630", // slightly lighter navy surface + Border: "#1C2A4A", // subtle border + + Text: "#F1F5F9", // slate-100 + Muted: "#CBD5E1", // slate-300 + Subtle: "#94A3B8", // slate-400 + } +} + +// WelcomeEmailHTML returns a dark, theme-aligned HTML email. +// Table-based for best compatibility across email clients. +func WelcomeEmailHTML(username, docsURL, logoURL string) string { if strings.TrimSpace(docsURL) == "" { docsURL = "https://docs.quantumauth.io" } - u := strings.TrimSpace(username) if u == "" { u = "there" } - // Escape any user-provided data to avoid HTML injection. uEsc := html.EscapeString(u) docsEsc := html.EscapeString(docsURL) + logoEsc := html.EscapeString(strings.TrimSpace(logoURL)) + + th := qaEmailTheme() return fmt.Sprintf(` - - + +
- - - - - - +
-
- QuantumAuth -
-
- - -
-
Welcome, %s đź‘‹
+
+ + + + + + -
- Your account is ready. You’ve joined QuantumAuth — a platform designed to help you build - and use secure, modern authentication. -
+ + + +
+ + + + +
+ QuantumAuth +
+ +
+ Hardware-aware, quantum-resistant authentication. +
+
-
- Next step: read the docs and try the quickstart. -
+ + + + + +
+
+ Welcome, %s đź‘‹ +
+ +
+ Your account is ready. You’ve joined QuantumAuth — built to help you ship secure, + modern authentication without the usual friction. +
+ +
+ Next step: read the docs and try the quickstart. +
+ + + + + + +
+ + Open QuantumAuth Docs + +
+ +
+ If the button doesn’t work, copy and paste this link:
+ %s +
+
- - + +
-
- - Open QuantumAuth Docs - + + You received this email because a QuantumAuth account was created with this address.
-
- If the button doesn’t work, copy and paste this link:
- %s -
- You received this email because a QuantumAuth account was created with this address. + + © %d QuantumAuth
-
- © %d QuantumAuth -
-`, uEsc, docsEsc, docsEsc, docsEsc, currentYear()) +`, + th.Bg950, th.Bg950, // page bg + th.Surface, th.Border, // card + th.Bg900, logoEsc, // header bg + logo + th.Muted, // tagline + th.Primary500, // divider + th.Text, th.Surface, // body container + th.Text, uEsc, // title + name + th.Muted, th.Text, // paragraph + QuantumAuth emphasis + th.Muted, // next step + th.Primary600, docsEsc, // button + th.Subtle, docsEsc, th.Accent500, docsEsc, // link + th.Bg900, th.Subtle, th.Border, // footer + th.Subtle, currentYear(), // copyright + ) } func WelcomeEmailText(username, docsURL string) string { @@ -104,7 +185,7 @@ func WelcomeEmailText(username, docsURL string) string { Welcome, %s! -Your account is ready. You’ve joined QuantumAuth — a platform designed to help you build and use secure, modern authentication. +Your account is ready. You’ve joined QuantumAuth — built to help you ship secure, modern authentication without the usual friction. Next step: read the docs and try the quickstart: %s @@ -115,7 +196,6 @@ If you didn’t create this account, you can ignore this email. `, u, docsURL, currentYear()) } -// keep this tiny and dependency-free func currentYear() int { return time.Now().UTC().Year() } diff --git a/internal/quantum/http/dto.go b/internal/quantum/http/dto.go index 62e1b1d..200589a 100644 --- a/internal/quantum/http/dto.go +++ b/internal/quantum/http/dto.go @@ -123,3 +123,13 @@ type fullLoginResponse struct { UserID string `json:"user_id"` DeviceID string `json:"device_id"` } + +type newsletterRequest struct { + Email string `json:"email" binding:"required,email"` +} + +type newsletterResponse struct { + NewsletterID string `json:"newsletter_id,omitempty"` + Email string `json:"email"` + Subscribed bool `json:"subscribed"` +} diff --git a/internal/quantum/http/handlers.go b/internal/quantum/http/handlers.go index 0253cb9..b476c24 100644 --- a/internal/quantum/http/handlers.go +++ b/internal/quantum/http/handlers.go @@ -30,6 +30,10 @@ type QuantumAuthRepository interface { CreateChallenge(ctx context.Context, in *qdb.CreateChallengeInput) (string, error) DeleteChallenge(ctx context.Context, id string) error + + SubscribeNewsletter(ctx context.Context, in qdb.SubscribeNewsletterInput) (string, error) + UnsubscribeNewsletter(ctx context.Context, in qdb.UnsubscribeNewsletterInput) (string, error) + GetNewsletterByEmail(ctx context.Context, email string) (*qdb.NewsletterSubscription, error) } type Handler struct { ctx context.Context @@ -105,7 +109,8 @@ func (h *Handler) RegisterUser(c *gin.Context) { return } - docsURL := "https://docs.quantumauth.io" // or config/env + docsURL := "https://docs.quantumauth.io" + logoURL := "https://quantumauth.io/logo.png" err = h.emailSender.Send(c, email.Message{ FromName: "QuantumAuth", @@ -113,7 +118,7 @@ func (h *Handler) RegisterUser(c *gin.Context) { To: req.Email, Subject: "Welcome to QuantumAuth", TextBody: email.WelcomeEmailText(req.Username, docsURL), - HTMLBody: email.WelcomeEmailHTML(req.Username, docsURL), + HTMLBody: email.WelcomeEmailHTML(req.Username, docsURL, logoURL), }) if err != nil { @@ -537,3 +542,73 @@ func (h *Handler) FullLogin(c *gin.Context) { DeviceID: d.ID, }) } + +// NewsletterSubscribe +// @BasePath /quantum-auth/v1 +// @Summary Subscribe to newsletter +// @Description Creates or re-subscribes an email to the newsletter +// @Tags newsletter +// @Accept json +// @Produce json +// @Param payload body newsletterRequest true "Newsletter subscribe payload" +// @Success 201 {object} newsletterResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /newsletter/subscribe [post] +func (h *Handler) NewsletterSubscribe(c *gin.Context) { + ctx := c.Request.Context() + + var req newsletterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := h.repo.SubscribeNewsletter(ctx, qdb.SubscribeNewsletterInput{Email: req.Email}) + if err != nil { + log.Error("SubscribeNewsletter failed", "error", err, "email", req.Email) + c.JSON(http.StatusInternalServerError, gin.H{"error": "subscribe failed"}) + return + } + + c.JSON(http.StatusCreated, newsletterResponse{ + NewsletterID: id, + Email: req.Email, + Subscribed: true, + }) +} + +// NewsletterUnsubscribe +// @BasePath /quantum-auth/v1 +// @Summary Unsubscribe from newsletter +// @Description Marks an email as unsubscribed (soft unsubscribe) +// @Tags newsletter +// @Accept json +// @Produce json +// @Param payload body newsletterRequest true "Newsletter unsubscribe payload" +// @Success 200 {object} newsletterResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /newsletter/unsubscribe [post] +func (h *Handler) NewsletterUnsubscribe(c *gin.Context) { + ctx := c.Request.Context() + + var req newsletterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := h.repo.UnsubscribeNewsletter(ctx, qdb.UnsubscribeNewsletterInput{Email: req.Email}) + if err != nil { + log.Error("UnsubscribeNewsletter failed", "error", err, "email", req.Email) + c.JSON(http.StatusInternalServerError, gin.H{"error": "unsubscribe failed"}) + return + } + + c.JSON(http.StatusOK, newsletterResponse{ + NewsletterID: id, + Email: req.Email, + Subscribed: false, + }) +} diff --git a/internal/quantum/http/http.go b/internal/quantum/http/http.go index 74b4d00..d450383 100644 --- a/internal/quantum/http/http.go +++ b/internal/quantum/http/http.go @@ -2,8 +2,11 @@ package http import ( "context" + "time" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/quantumauth-io/quantum-auth/docs" _ "github.com/quantumauth-io/quantum-auth/docs" "github.com/quantumauth-io/quantum-auth/internal/quantum/email" qamw "github.com/quantumauth-io/quantum-auth/internal/quantum/transport/http/middleware" @@ -35,6 +38,21 @@ func NewRouter(ctx context.Context, repo QuantumAuthRepository, emailSender *ema r.Use(gin.Recovery()) _ = r.SetTrustedProxies(nil) + // ---- CORS (must be BEFORE routes) ---- + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{ + "http://localhost:4321", + "http://127.0.0.1:4321", + "https://dev.quantumauth.io", + "https://quantumauth.io", + }, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: false, + MaxAge: 12 * time.Hour, + })) + api := r.Group(ApiBase) // ---- MAIN QuantumAuth API ---- @@ -51,7 +69,16 @@ func (r *Routes) Register(api *gin.RouterGroup) { }) // ---- Swagger ---- - api.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + api.GET("/swagger/*any", func(c *gin.Context) { + // If you're using swaggo docs package: + // import "github.com/quantumauth-io/quantum-auth/docs" + docs.SwaggerInfo.Host = c.Request.Host + docs.SwaggerInfo.Schemes = []string{"https"} + if c.Request.TLS == nil { + docs.SwaggerInfo.Schemes = []string{"http"} + } + ginSwagger.WrapHandler(swaggerFiles.Handler)(c) + }) // ---- Users ---- api.POST("/users/register", r.h.RegisterUser) @@ -64,6 +91,10 @@ func (r *Routes) Register(api *gin.RouterGroup) { api.POST("/auth/verify", r.h.AuthVerify) api.POST("/auth/full-login", r.h.FullLogin) + // ---- Newsletter ---- + api.POST("/newsletter/subscribe", r.h.NewsletterSubscribe) + api.POST("/newsletter/unsubscribe", r.h.NewsletterUnsubscribe) + // ---- Protected routes ---- secured := api.Group("/api") secured.Use(qamw.QuantumAuthMiddleware(qamw.Config{ diff --git a/internal/quantum/setup.go b/internal/quantum/setup.go index 91da6c8..c950b01 100644 --- a/internal/quantum/setup.go +++ b/internal/quantum/setup.go @@ -36,8 +36,6 @@ type Config struct { RedisConfig rdb.Config } -const ApiBase = "/quantum-auth/v1" - //go:embed database/migrations/*.sql var fs embed.FS