Compare commits

9 Commits

Author SHA1 Message Date
95294686b7 feat: PasswordResetBegin of auth controller;
fix: sql query updateLoginInformationByUsername used in-database hashing;
refactor: renamed LogOutAccounts into LogOutSessions in models/auth;
refactor: added error checks on opening transactions for all auth service methods;
refactor: added error checks on commiting transactions likewise;
refactor: simplified PasswordResetBegin logic;
feat: implemented PasswordResetComplete method of auth service;
2025-07-13 19:10:34 +03:00
65ea47dbb6 feat: new RollbackOnError method added for transactional db helper and integrated into auth service 2025-07-13 15:57:34 +03:00
a3bebd89be chore: todo comments;
fix: txless creation of confirmation code in passwordresetbegin
2025-07-13 02:18:46 +03:00
a2dd8993a6 feat: auth service logic for purging expired unverified accounts upon registration, new sql queries for this purpose 2025-07-13 01:57:19 +03:00
8fa57eddb1 feat: implemented PasswordResetBegin method in auth service with cooldown for each email being stored in redis 2025-07-12 19:32:53 +03:00
b91ff2c802 refactor: added redisclient connection error check 2025-07-12 17:04:09 +03:00
541847221b chore: tidy swagger comments;
feat: password reset models;
feat: verification code validator
2025-07-11 17:44:48 +03:00
c988a16783 refactor: removed error logs from smtp service since they are redundant 2025-07-10 12:21:23 +03:00
f59b647b27 feat: development compose file;
fix: smtp service
2025-07-10 01:51:48 +03:00
17 changed files with 1235 additions and 120 deletions

View File

@@ -45,6 +45,7 @@ import (
"easywish/internal/controllers"
"easywish/internal/database"
"easywish/internal/logger"
redisclient "easywish/internal/redisClient"
"easywish/internal/routes"
"easywish/internal/services"
"easywish/internal/validation"
@@ -59,10 +60,13 @@ func main() {
panic(err)
}
cfg := config.GetConfig()
fx.New(
fx.Provide(
logger.NewLogger,
logger.NewSyncLogger,
redisclient.NewRedisClient,
gin.Default,
),
database.Module,
@@ -80,7 +84,7 @@ func main() {
// Gin
server := &http.Server{
Addr: fmt.Sprintf(":%s", strconv.Itoa(int(config.GetConfig().Port))),
Addr: fmt.Sprintf(":%s", strconv.Itoa(int(cfg.Port))),
Handler: router,
}

View File

@@ -52,7 +52,7 @@ const docTemplate = `{
"summary": "Acquire tokens via login credentials (and 2FA code if needed)",
"parameters": [
{
"description": "desc",
"description": " ",
"name": "request",
"in": "body",
"required": true,
@@ -63,10 +63,13 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "desc",
"description": " ",
"schema": {
"$ref": "#/definitions/models.LoginResponse"
}
},
"403": {
"description": "Invalid login credentials"
}
}
}
@@ -83,7 +86,25 @@ const docTemplate = `{
"Auth"
],
"summary": "Request password reset email",
"responses": {}
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Reset code sent to the email if it is attached to an account"
},
"429": {
"description": "Too many recent requests for this email"
}
}
}
},
"/auth/passwordResetComplete": {
@@ -97,8 +118,29 @@ const docTemplate = `{
"tags": [
"Auth"
],
"summary": "Complete password reset with email code and provide 2FA code or backup code if needed",
"responses": {}
"summary": "Complete password reset via email code",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteResponse"
}
},
"403": {
"description": "Wrong verification code or username"
}
}
}
},
"/auth/refresh": {
@@ -113,7 +155,28 @@ const docTemplate = `{
"Auth"
],
"summary": "Receive new tokens via refresh token",
"responses": {}
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RefreshResponse"
}
},
"401": {
"description": "Invalid refresh token"
}
}
}
},
"/auth/registrationBegin": {
@@ -130,7 +193,7 @@ const docTemplate = `{
"summary": "Register an account",
"parameters": [
{
"description": "desc",
"description": " ",
"name": "request",
"in": "body",
"required": true,
@@ -145,6 +208,9 @@ const docTemplate = `{
},
"409": {
"description": "Username or email is already taken"
},
"429": {
"description": "Too many recent registration attempts for this email"
}
}
}
@@ -163,7 +229,7 @@ const docTemplate = `{
"summary": "Confirm with code, finish creating the account",
"parameters": [
{
"description": "desc",
"description": " ",
"name": "request",
"in": "body",
"required": true,
@@ -174,10 +240,13 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "desc",
"description": " ",
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse"
}
},
"403": {
"description": "Invalid email or verification code"
}
}
}
@@ -354,9 +423,76 @@ const docTemplate = `{
}
}
},
"models.PasswordResetBeginRequest": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
},
"models.PasswordResetCompleteRequest": {
"type": "object",
"required": [
"email",
"password",
"verification_code"
],
"properties": {
"email": {
"type": "string"
},
"log_out_accounts": {
"type": "boolean"
},
"password": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.PasswordResetCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string"
}
}
},
"models.RefreshResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RegistrationBeginRequest": {
"type": "object",
"required": [
"email",
"password",
"username"
],

View File

@@ -48,7 +48,7 @@
"summary": "Acquire tokens via login credentials (and 2FA code if needed)",
"parameters": [
{
"description": "desc",
"description": " ",
"name": "request",
"in": "body",
"required": true,
@@ -59,10 +59,13 @@
],
"responses": {
"200": {
"description": "desc",
"description": " ",
"schema": {
"$ref": "#/definitions/models.LoginResponse"
}
},
"403": {
"description": "Invalid login credentials"
}
}
}
@@ -79,7 +82,25 @@
"Auth"
],
"summary": "Request password reset email",
"responses": {}
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Reset code sent to the email if it is attached to an account"
},
"429": {
"description": "Too many recent requests for this email"
}
}
}
},
"/auth/passwordResetComplete": {
@@ -93,8 +114,29 @@
"tags": [
"Auth"
],
"summary": "Complete password reset with email code and provide 2FA code or backup code if needed",
"responses": {}
"summary": "Complete password reset via email code",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteResponse"
}
},
"403": {
"description": "Wrong verification code or username"
}
}
}
},
"/auth/refresh": {
@@ -109,7 +151,28 @@
"Auth"
],
"summary": "Receive new tokens via refresh token",
"responses": {}
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RefreshResponse"
}
},
"401": {
"description": "Invalid refresh token"
}
}
}
},
"/auth/registrationBegin": {
@@ -126,7 +189,7 @@
"summary": "Register an account",
"parameters": [
{
"description": "desc",
"description": " ",
"name": "request",
"in": "body",
"required": true,
@@ -141,6 +204,9 @@
},
"409": {
"description": "Username or email is already taken"
},
"429": {
"description": "Too many recent registration attempts for this email"
}
}
}
@@ -159,7 +225,7 @@
"summary": "Confirm with code, finish creating the account",
"parameters": [
{
"description": "desc",
"description": " ",
"name": "request",
"in": "body",
"required": true,
@@ -170,10 +236,13 @@
],
"responses": {
"200": {
"description": "desc",
"description": " ",
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse"
}
},
"403": {
"description": "Invalid email or verification code"
}
}
}
@@ -350,9 +419,76 @@
}
}
},
"models.PasswordResetBeginRequest": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
},
"models.PasswordResetCompleteRequest": {
"type": "object",
"required": [
"email",
"password",
"verification_code"
],
"properties": {
"email": {
"type": "string"
},
"log_out_accounts": {
"type": "boolean"
},
"password": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.PasswordResetCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string"
}
}
},
"models.RefreshResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RegistrationBeginRequest": {
"type": "object",
"required": [
"email",
"password",
"username"
],

View File

@@ -27,6 +27,49 @@ definitions:
refresh_token:
type: string
type: object
models.PasswordResetBeginRequest:
properties:
email:
type: string
required:
- email
type: object
models.PasswordResetCompleteRequest:
properties:
email:
type: string
log_out_accounts:
type: boolean
password:
type: string
verification_code:
type: string
required:
- email
- password
- verification_code
type: object
models.PasswordResetCompleteResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.RefreshRequest:
properties:
refresh_token:
type: string
required:
- refresh_token
type: object
models.RefreshResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.RegistrationBeginRequest:
properties:
email:
@@ -36,6 +79,7 @@ definitions:
username:
type: string
required:
- email
- password
- username
type: object
@@ -86,7 +130,7 @@ paths:
consumes:
- application/json
parameters:
- description: desc
- description: ' '
in: body
name: request
required: true
@@ -96,9 +140,11 @@ paths:
- application/json
responses:
"200":
description: desc
description: ' '
schema:
$ref: '#/definitions/models.LoginResponse'
"403":
description: Invalid login credentials
summary: Acquire tokens via login credentials (and 2FA code if needed)
tags:
- Auth
@@ -106,9 +152,20 @@ paths:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.PasswordResetBeginRequest'
produces:
- application/json
responses: {}
responses:
"200":
description: Reset code sent to the email if it is attached to an account
"429":
description: Too many recent requests for this email
summary: Request password reset email
tags:
- Auth
@@ -116,20 +173,45 @@ paths:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.PasswordResetCompleteRequest'
produces:
- application/json
responses: {}
summary: Complete password reset with email code and provide 2FA code or backup
code if needed
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.PasswordResetCompleteResponse'
"403":
description: Wrong verification code or username
summary: Complete password reset via email code
tags:
- Auth
/auth/refresh:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RefreshRequest'
produces:
- application/json
responses: {}
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.RefreshResponse'
"401":
description: Invalid refresh token
summary: Receive new tokens via refresh token
tags:
- Auth
@@ -138,7 +220,7 @@ paths:
consumes:
- application/json
parameters:
- description: desc
- description: ' '
in: body
name: request
required: true
@@ -151,6 +233,8 @@ paths:
description: Account is created and awaiting verification
"409":
description: Username or email is already taken
"429":
description: Too many recent registration attempts for this email
summary: Register an account
tags:
- Auth
@@ -159,7 +243,7 @@ paths:
consumes:
- application/json
parameters:
- description: desc
- description: ' '
in: body
name: request
required: true
@@ -169,9 +253,11 @@ paths:
- application/json
responses:
"200":
description: desc
description: ' '
schema:
$ref: '#/definitions/models.RegistrationCompleteResponse'
"403":
description: Invalid email or verification code
summary: Confirm with code, finish creating the account
tags:
- Auth

View File

@@ -19,7 +19,9 @@ require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -29,8 +31,10 @@ require (
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

View File

@@ -5,12 +5,16 @@ github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -39,6 +43,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -48,6 +54,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@@ -42,20 +42,21 @@ type AuthController interface {
}
type authControllerImpl struct {
authService services.AuthService
log *zap.Logger
auth services.AuthService
}
func NewAuthController(_log *zap.Logger, as services.AuthService) AuthController {
return &authControllerImpl{log: _log, authService: as}
func NewAuthController(_log *zap.Logger, _auth services.AuthService) AuthController {
return &authControllerImpl{log: _log, auth: _auth}
}
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true "desc"
// @Success 200 {object} models.LoginResponse "desc"
// @Param request body models.LoginRequest true " "
// @Success 200 {object} models.LoginResponse " "
// @Failure 403 "Invalid login credentials"
// @Router /auth/login [post]
func (a *authControllerImpl) Login(c *gin.Context) {
request, ok := utils.GetRequest[models.LoginRequest](c)
@@ -64,7 +65,7 @@ func (a *authControllerImpl) Login(c *gin.Context) {
return
}
response, err := a.authService.Login(request.Body)
response, err := a.auth.Login(request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
@@ -83,25 +84,52 @@ func (a *authControllerImpl) Login(c *gin.Context) {
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetBeginRequest true " "
// @Router /auth/passwordResetBegin [post]
// @Success 200 "Reset code sent to the email if it is attached to an account"
// @Failure 429 "Too many recent requests for this email"
func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
c.Status(http.StatusNotImplemented)
request, ok := utils.GetRequest[models.PasswordResetBeginRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
response, err := a.auth.PasswordResetBegin(request.Body)
if err != nil {
if errors.Is(err, errs.ErrTooManyRequests) {
c.Status(http.StatusTooManyRequests)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
return
}
// @Summary Complete password reset with email code and provide 2FA code or backup code if needed
// @Summary Complete password reset via email code
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetCompleteRequest true " "
// @Router /auth/passwordResetComplete [post]
// @Success 200 {object} models.PasswordResetCompleteResponse " "
// @Success 403 "Wrong verification code or username"
func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Receive new tokens via refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RefreshRequest true " "
// @Router /auth/refresh [post]
// @Success 200 {object} models.RefreshResponse " "
// @Failure 401 "Invalid refresh token"
func (a *authControllerImpl) Refresh(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
@@ -110,9 +138,10 @@ func (a *authControllerImpl) Refresh(c *gin.Context) {
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RegistrationBeginRequest true "desc"
// @Param request body models.RegistrationBeginRequest true " "
// @Success 200 "Account is created and awaiting verification"
// @Success 409 "Username or email is already taken"
// @Failure 409 "Username or email is already taken"
// @Failure 429 "Too many recent registration attempts for this email"
// @Router /auth/registrationBegin [post]
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
@@ -122,7 +151,7 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
return
}
_, err := a.authService.RegistrationBegin(request.Body)
_, err := a.auth.RegistrationBegin(request.Body)
if err != nil {
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
@@ -141,8 +170,9 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RegistrationCompleteRequest true "desc"
// @Success 200 {object} models.RegistrationCompleteResponse "desc"
// @Param request body models.RegistrationCompleteRequest true " "
// @Success 200 {object} models.RegistrationCompleteResponse " "
// @Failure 403 "Invalid email or verification code"
// @Router /auth/registrationComplete [post]
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c)
@@ -151,7 +181,7 @@ func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
return
}
response, err := a.authService.RegistrationComplete(request.Body)
response, err := a.auth.RegistrationComplete(request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
@@ -172,6 +202,6 @@ func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete)
group.POST("/login", middleware.RequestMiddleware[models.LoginRequest](enums.GuestRole), a.Login)
group.POST("/refresh", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.UserRole), a.Refresh)
group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetBegin)
group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.PasswordResetBeginRequest](enums.GuestRole), a.PasswordResetBegin)
group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetComplete)
}

View File

@@ -26,6 +26,7 @@ import (
type DbHelperTransaction interface {
Commit() error
Rollback() error
RollbackOnError(err error) error
}
type DbHelper struct {
@@ -98,3 +99,11 @@ func (d *dbHelperTransactionImpl) Rollback() error {
}
return nil
}
// RollbackOnError implements DbHelperTransaction.
func (d *dbHelperTransactionImpl) RollbackOnError(err error) error {
if err != nil {
return d.Rollback()
}
return nil
}

View File

@@ -11,6 +11,48 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const checkUserRegistrationAvailability = `-- name: CheckUserRegistrationAvailability :one
SELECT
COUNT(CASE WHEN users.username = $1::text THEN 1 END) > 0 AS username_busy,
COUNT(CASE WHEN linfo.email = $2::text THEN 1 END) > 0 AS email_busy
FROM users
JOIN login_informations AS linfo ON linfo.user_id = users.id
WHERE
(
users.username = $1::text OR
linfo.email = $2::text
)
AND
(
users.verified IS TRUE OR
NOT EXISTS (
SELECT 1
FROM confirmation_codes AS codes
WHERE codes.user_id = users.id
AND codes.code_type = 0
AND codes.deleted IS FALSE
AND codes.expires_at > CURRENT_TIMESTAMP
)
)
`
type CheckUserRegistrationAvailabilityParams struct {
Username string
Email string
}
type CheckUserRegistrationAvailabilityRow struct {
UsernameBusy bool
EmailBusy bool
}
func (q *Queries) CheckUserRegistrationAvailability(ctx context.Context, arg CheckUserRegistrationAvailabilityParams) (CheckUserRegistrationAvailabilityRow, error) {
row := q.db.QueryRow(ctx, checkUserRegistrationAvailability, arg.Username, arg.Email)
var i CheckUserRegistrationAvailabilityRow
err := row.Scan(&i.UsernameBusy, &i.EmailBusy)
return i, err
}
const createBannedUser = `-- name: CreateBannedUser :one
INSERT INTO banned_users(user_id, expires_at, reason, banned_by)
VALUES ( $1, $2, $3, $4) RETURNING id, user_id, date, reason, expires_at, banned_by, pardoned, pardoned_by
@@ -209,6 +251,35 @@ func (q *Queries) CreateUser(ctx context.Context, username string) (User, error)
return i, err
}
const deleteUnverifiedAccountsHavingUsernameOrEmail = `-- name: DeleteUnverifiedAccountsHavingUsernameOrEmail :one
WITH deleted_rows AS (
DELETE FROM users
WHERE
(username = $1::text OR
EXISTS (
SELECT 1
FROM login_informations AS linfo
WHERE linfo.user_id = users.id
AND linfo.email = $2::text
))
AND verified IS FALSE
RETURNING id, username, verified, registration_date, deleted
)
SELECT COUNT(*) AS deleted_count FROM deleted_rows
`
type DeleteUnverifiedAccountsHavingUsernameOrEmailParams struct {
Username string
Email string
}
func (q *Queries) DeleteUnverifiedAccountsHavingUsernameOrEmail(ctx context.Context, arg DeleteUnverifiedAccountsHavingUsernameOrEmailParams) (int64, error) {
row := q.db.QueryRow(ctx, deleteUnverifiedAccountsHavingUsernameOrEmail, arg.Username, arg.Email)
var deleted_count int64
err := row.Scan(&deleted_count)
return deleted_count, err
}
const deleteUser = `-- name: DeleteUser :exec
DELETE FROM users
WHERE id = $1
@@ -603,6 +674,69 @@ func (q *Queries) GetValidConfirmationCodeByCode(ctx context.Context, arg GetVal
return i, err
}
const getValidConfirmationCodesByUsername = `-- name: GetValidConfirmationCodesByUsername :many
SELECT confirmation_codes.id, user_id, code_type, code_hash, expires_at, used, confirmation_codes.deleted, users.id, username, verified, registration_date, users.deleted FROM confirmation_codes
JOIN users on users.id = confirmation_codes.user_id
WHERE
users.username = $1::text AND
code_type = $2::integer AND
expires_at > CURRENT_TIMESTAMP AND
used IS FALSE
`
type GetValidConfirmationCodesByUsernameParams struct {
Username string
CodeType int32
}
type GetValidConfirmationCodesByUsernameRow struct {
ID int64
UserID int64
CodeType int32
CodeHash string
ExpiresAt pgtype.Timestamp
Used *bool
Deleted *bool
ID_2 int64
Username string
Verified *bool
RegistrationDate pgtype.Timestamp
Deleted_2 *bool
}
func (q *Queries) GetValidConfirmationCodesByUsername(ctx context.Context, arg GetValidConfirmationCodesByUsernameParams) ([]GetValidConfirmationCodesByUsernameRow, error) {
rows, err := q.db.Query(ctx, getValidConfirmationCodesByUsername, arg.Username, arg.CodeType)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetValidConfirmationCodesByUsernameRow
for rows.Next() {
var i GetValidConfirmationCodesByUsernameRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.CodeType,
&i.CodeHash,
&i.ExpiresAt,
&i.Used,
&i.Deleted,
&i.ID_2,
&i.Username,
&i.Verified,
&i.RegistrationDate,
&i.Deleted_2,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getValidUserByLoginCredentials = `-- name: GetValidUserByLoginCredentials :one
SELECT
users.id,
@@ -731,13 +865,7 @@ const updateLoginInformationByUsername = `-- name: UpdateLoginInformationByUsern
UPDATE login_informations
SET
email = COALESCE($2, email),
password_hash = COALESCE(
CASE
WHEN $3::text IS NOT NULL
THEN crypt($3::text, gen_salt('bf'))
END,
password_hash
),
password_hash = COALESCE($3::text, password_hash),
totp_encrypted = COALESCE($4, totp_encrypted),
email_2fa_enabled = COALESCE($5, email_2fa_enabled),
password_change_date = COALESCE($6, password_change_date)
@@ -748,7 +876,7 @@ WHERE users.username = $1 AND login_informations.user_id = users.id
type UpdateLoginInformationByUsernameParams struct {
Username string
Email *string
Password string
PasswordHash string
TotpEncrypted *string
Email2faEnabled *bool
PasswordChangeDate pgtype.Timestamp
@@ -758,7 +886,7 @@ func (q *Queries) UpdateLoginInformationByUsername(ctx context.Context, arg Upda
_, err := q.db.Exec(ctx, updateLoginInformationByUsername,
arg.Username,
arg.Email,
arg.Password,
arg.PasswordHash,
arg.TotpEncrypted,
arg.Email2faEnabled,
arg.PasswordChangeDate,

View File

@@ -25,4 +25,5 @@ var (
ErrNotImplemented = errors.New("Feature is not implemented")
ErrBadRequest = errors.New("Bad request")
ErrForbidden = errors.New("Access is denied")
ErrTooManyRequests = errors.New("Too many requests")
)

View File

@@ -30,7 +30,7 @@ type RegistrationBeginRequest struct {
type RegistrationCompleteRequest struct {
Username string `json:"username" binding:"required" validate:"username"`
VerificationCode string `json:"verification_code" binding:"required"`
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"`
Name string `json:"name" binding:"required" validate:"name"`
Birthday *string `json:"birthday"`
}
@@ -57,3 +57,18 @@ type RefreshRequest struct {
type RefreshResponse struct {
Tokens
}
type PasswordResetBeginRequest struct {
Email string `json:"email" binding:"required,email"`
}
type PasswordResetCompleteRequest struct {
Email string `json:"email" binding:"required,email"`
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reset"`
NewPassword string `json:"password" binding:"required" validate:"password"`
LogOutSessions bool `json:"log_out_sessions"`
}
type PasswordResetCompleteResponse struct {
Tokens
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package redisclient
import (
"context"
"easywish/config"
"github.com/go-redis/redis/v8"
)
func NewRedisClient() *redis.Client {
cfg := config.GetConfig()
options, err := redis.ParseURL(cfg.RedisUrl)
if err != nil {
panic("Failed to parse redis URL: " + err.Error())
}
client := redis.NewClient(options)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
panic("Redis connection failed: " + err.Error())
}
return client
}

View File

@@ -18,6 +18,7 @@
package services
import (
"context"
"easywish/config"
"easywish/internal/database"
errs "easywish/internal/errors"
@@ -26,7 +27,10 @@ import (
"easywish/internal/utils/enums"
"errors"
"fmt"
"time"
"github.com/go-redis/redis/v8"
"github.com/google/uuid"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
@@ -37,41 +41,85 @@ type AuthService interface {
RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error)
Login(request models.LoginRequest) (*models.LoginResponse, error)
Refresh(request models.RefreshRequest) (*models.RefreshResponse, error)
PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error)
PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error)
}
type authServiceImpl struct {
log *zap.Logger
smtp SmtpService
dbctx database.DbContext
redis *redis.Client
smtp SmtpService
}
func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _smtp SmtpService) AuthService {
return &authServiceImpl{log: _log, dbctx: _dbctx, smtp: _smtp}
func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _smtp SmtpService) AuthService {
return &authServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, smtp: _smtp}
}
func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) {
var occupationStatus database.CheckUserRegistrationAvailabilityRow
var user database.User
var generatedCode string
var generatedCodeHash string
var passwordHash string
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
defer helper.Rollback()
var err error
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil {
if errs.MatchPgError(err, pgerrcode.UniqueViolation) {
a.log.Warn(
"Attempted registration for a taken username",
zap.String("username", request.Username),
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return false, errs.ErrUsernameTaken
return false, errs.ErrServerError
}
defer helper.RollbackOnError(err)
// TODO: check occupation with redis
if occupationStatus, err = db.TXQueries.CheckUserRegistrationAvailability(db.CTX, database.CheckUserRegistrationAvailabilityParams{
Email: request.Email,
Username: request.Username,
}); err != nil {
a.log.Error(
"Failed to check credentials availability for registration",
zap.String("username", request.Username),
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
}
if occupationStatus.UsernameBusy {
a.log.Warn(
"Attempted registration for a taken username",
zap.String("email", request.Email),
zap.String("username", request.Username))
return false, errs.ErrUsernameTaken
} else if occupationStatus.EmailBusy {
// Falsely confirm in order to avoid disclosing registered email addresses
// TODO: save this email into redis
a.log.Warn(
"Attempted registration for a taken email",
zap.String("email", request.Email),
zap.String("username", request.Username))
return true, nil
} else {
if _, err := db.TXQueries.DeleteUnverifiedAccountsHavingUsernameOrEmail(db.CTX, database.DeleteUnverifiedAccountsHavingUsernameOrEmailParams{
Username: request.Username,
Email: request.Email,
}); err != nil {
a.log.Error(
"Failed to purge unverified accounts as part of registration",
zap.String("email", request.Email),
zap.String("username", request.Username),
zap.Error(err))
return false, errs.ErrServerError
}
}
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil {
a.log.Error("Failed to add user to database", zap.Error(err))
return false, errs.ErrServerError
}
@@ -138,12 +186,17 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
} else {
a.log.Debug(
"Declated registration code for a new user. Enable SMTP in the config to disable this message",
"Declared registration code for a new user. Enable SMTP in the config to disable this message",
zap.String("username", user.Username),
zap.String("code", generatedCode))
}
helper.Commit()
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return false, errs.ErrServerError
}
return true, nil
}
@@ -157,8 +210,14 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
var accessToken, refreshToken string
var err error
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
defer helper.Rollback()
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.RollbackOnError(err)
user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username)
@@ -275,7 +334,12 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
return nil, errs.ErrServerError
}
helper.Commit()
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
a.log.Info(
"User verified registration",
@@ -293,12 +357,17 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginResponse, error) {
var userRow database.GetValidUserByLoginCredentialsRow
var session database.Session
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
defer helper.Rollback()
var err error
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.RollbackOnError(err)
userRow, err = db.TXQueries.GetValidUserByLoginCredentials(db.CTX, database.GetValidUserByLoginCredentialsParams{
Username: request.Username,
Password: request.Password,
@@ -355,7 +424,12 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
return nil, errs.ErrServerError
}
helper.Commit()
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
response := models.LoginResponse{Tokens: models.Tokens{
AccessToken: accessToken,
@@ -368,3 +442,241 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) {
return nil, errs.ErrNotImplemented
}
func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error) {
var user database.User
var generatedCode, hashedCode string
var err error
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return false, errs.ErrServerError
}
defer helper.RollbackOnError(err)
ctx := context.TODO()
cooldownTimeUnix, redisErr := a.redis.Get(ctx, fmt.Sprintf("email::%s::reset_cooldown", request.Email)).Int64()
if redisErr != nil && redisErr != redis.Nil {
a.log.Error(
"Failed to get reset_cooldown state for user",
zap.String("email", request.Email),
zap.Error(redisErr))
return false, errs.ErrServerError
}
if time.Now().Unix() < cooldownTimeUnix {
a.log.Warn(
"Attempted to request a new password reset code for email on active reset cooldown",
zap.String("email", request.Email))
return false, errs.ErrTooManyRequests
}
if user, err = db.TXQueries.GetUserByEmail(db.CTX, request.Email); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Enable cooldown for the email despite that account does not exist
err := a.redis.Set(
ctx,
fmt.Sprintf("email::%s::reset_cooldown", request.Email),
time.Now().Add(10*time.Minute),
time.Duration(10*time.Minute),
).Err()
if err != nil {
a.log.Error(
"Failed to set reset cooldown for email",
zap.Error(err))
return false, err
}
a.log.Warn(
"Requested password reset email for unexistent user",
zap.String("email", request.Email))
return true, nil
}
a.log.Error(
"Failed to retrieve user from database",
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
}
generatedCode = uuid.New().String()
if hashedCode, err = utils.HashPassword(generatedCode); err != nil {
a.log.Error(
"Failed to hash password reset code for user",
zap.String("username", user.Username),
zap.Error(err))
return false, errs.ErrServerError
}
if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{
UserID: user.ID,
CodeType: int32(enums.PasswordResetCodeType),
CodeHash: hashedCode,
}); err != nil {
a.log.Error(
"Failed to save user password reset code to the database",
zap.String("username", user.Username),
zap.Error(err))
}
err = a.redis.Set(
ctx,
fmt.Sprintf("email::%s::reset_cooldown", request.Email),
time.Now().Add(10*time.Minute),
time.Duration(10*time.Minute),
).Err()
if err != nil {
a.log.Error(
"Failed to set reset cooldown for email. Cancelling password reset",
zap.Error(err))
return false, err
}
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return false, errs.ErrServerError
}
return true, nil
}
func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error) {
var resetCode database.ConfirmationCode
var user database.User
var session database.Session
var hashedPassword, accessToken, refreshToken string
var err error
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
if user, err = db.TXQueries.GetUserByEmail(db.CTX, request.Email); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.log.Warn(
"Attempted to complete password reset for unregistered email",
zap.String("email", request.Email),
zap.Error(err))
return nil, errs.ErrForbidden
}
a.log.Error(
"Failed to look up user of email while trying to complete password reset",
zap.String("email", request.Email),
zap.Error(err))
return nil, errs.ErrServerError
}
if resetCode, err = db.TXQueries.GetValidConfirmationCodeByCode(db.CTX, database.GetValidConfirmationCodeByCodeParams{
UserID: user.ID,
CodeType: int32(enums.PasswordResetCodeType),
Code: request.VerificationCode,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.log.Warn(
"Attempted to reset password for user using incorrect confirmation code",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.String("provided_code", request.VerificationCode),
zap.Error(err))
return nil, errs.ErrForbidden
}
}
if err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: resetCode.ID,
Used: utils.NewPointer(true),
}); err != nil {
a.log.Error(
"Failed to invalidate password reset code upon use",
zap.String("username", user.Username),
zap.String("email", request.Email),
zap.Int64("code_id", resetCode.ID),
zap.Error(err))
return nil, errs.ErrServerError
}
if hashedPassword, err = utils.HashPassword(request.NewPassword); err != nil {
a.log.Error(
"Failed to hash new password as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
if err = db.TXQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{
Username: user.Username,
PasswordHash: hashedPassword,
}); err != nil {
a.log.Error(
"Failed to save new password to database as part of user password reset",
zap.String("username", user.Username),
zap.String("email", request.Email),
zap.Error(err))
}
if request.LogOutSessions {
if err = db.TXQueries.TerminateAllSessionsForUserByUsername(db.CTX, user.Username); err != nil {
a.log.Error(
"Failed to log out older sessions as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
}
if session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
UserID: user.ID,
Name: utils.NewPointer("First device"),
Platform: utils.NewPointer("Unknown"),
LatestIp: utils.NewPointer("Unknown"),
}); err != nil {
a.log.Error(
"Failed to create new session for user as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
}
if accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String()); err != nil {
a.log.Error(
"Failed to generate tokens as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
response := models.PasswordResetCompleteResponse{
Tokens: models.Tokens{
AccessToken: accessToken,
RefreshToken: refreshToken,
},
}
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
return &response, nil
}

View File

@@ -26,7 +26,6 @@ import (
"strings"
"time"
"go.uber.org/zap"
errs "easywish/internal/errors"
)
@@ -35,23 +34,20 @@ type SmtpService interface {
}
type smtpServiceImpl struct {
log *zap.Logger
}
func NewSmtpService(_log *zap.Logger) SmtpService {
return &smtpServiceImpl{log: _log}
func NewSmtpService() SmtpService {
return &smtpServiceImpl{}
}
func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
cfg := config.GetConfig()
if !cfg.SmtpEnabled {
s.log.Error("Attempted to send an email with SMTP disabled in the config")
return errs.ErrSmtpDisabled
}
if cfg.SmtpServer == "" || cfg.SmtpPort == 0 || cfg.SmtpFrom == "" {
s.log.Error("SMTP service settings or the SMTP From paramater are not set")
return errs.ErrSmtpMissingConfiguration
}
@@ -72,7 +68,7 @@ func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
sb.WriteString("\r\n" + body)
message := []byte(sb.String())
hostPort := fmt.Sprintf("%s:%d", cfg.SmtpServer, cfg.SmtpPort)
hostPort := net.JoinHostPort(cfg.SmtpServer, fmt.Sprintf("%d", cfg.SmtpPort))
var conn net.Conn
var err error
@@ -84,14 +80,12 @@ func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
conn, err = net.DialTimeout("tcp", hostPort, timeout)
}
if err != nil {
s.log.Error("SMTP connection failure", zap.Error(err))
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, cfg.SmtpServer)
if err != nil {
s.log.Error("SMTP client creation failed", zap.Error(err))
return err
}
defer client.Close()
@@ -107,19 +101,16 @@ func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
if cfg.SmtpUser != "" && cfg.SmtpPassword != "" {
auth := smtp.PlainAuth("", cfg.SmtpUser, cfg.SmtpPassword, cfg.SmtpServer)
if err = client.Auth(auth); err != nil {
s.log.Error("SMTP authentication failure", zap.Error(err))
return err
}
}
if err = client.Mail(cfg.SmtpFrom); err != nil {
s.log.Error("SMTP sender set failed", zap.Error(err))
return err
}
for _, recipient := range to {
if err = client.Rcpt(string(recipient)); err != nil {
s.log.Error("SMTP recipient set failed", zap.Error(err))
for _, recipient := range toSlice {
if err = client.Rcpt(recipient); err != nil {
return err
}
}
@@ -127,15 +118,12 @@ func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
// Send email body
w, err := client.Data()
if err != nil {
s.log.Error("SMTP data command failed", zap.Error(err))
return err
}
if _, err = w.Write(message); err != nil {
s.log.Error("SMTP message write failed", zap.Error(err))
return err
}
if err = w.Close(); err != nil {
s.log.Error("SMTP message close failed", zap.Error(err))
return err
}

View File

@@ -19,6 +19,7 @@ package validation
import (
"easywish/config"
"fmt"
"regexp"
"github.com/go-playground/validator/v10"
@@ -77,6 +78,25 @@ func GetCustomHandlers() []CustomValidatorHandler {
return true
}},
{
FieldName: "verification_code",
Function: func(fl validator.FieldLevel) bool {
codeType := fl.Param()
code := fl.Field().String()
if codeType == "reg" {
return regexp.MustCompile(`[\d]{6,6}`).MatchString(code)
}
if codeType == "reset" {
return regexp.MustCompile(
`^[{(]?([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})[})]?$`,
).MatchString(code)
}
panic(fmt.Sprintf("'%s' is not a valid verification code type", codeType))
}},
}
return handlers

127
dev-compose.yml Normal file
View File

@@ -0,0 +1,127 @@
services:
backend:
build: ./backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "/usr/bin/curl", "-f", "http://localhost:8080/api/service/health"]
interval: 5s
timeout: 10s
retries: 3
environment:
ENVIRONMENT: "development"
HOSTNAME: ${HOSTNAME}
PORT: ${PORT}
POSTGRES_URL: ${POSTGRES_URL}
REDIS_URL: ${REDIS_URL}
MINIO_URL: ${MINIO_URL}
JWT_ALGORITHM: ${JWT_ALGORITHM}
JWT_SECRET: ${JWT_SECRET}
JWT_ISSUER: ${JWT_ISSUER}
JWT_AUDIENCE: ${JWT_AUDIENCE}
JWT_EXP_ACCESS: ${JWT_EXP_ACCESS}
JWT_EXP_REFRESH: ${JWT_EXP_REFRESH}
SMTP_ENABLED: ${SMTP_ENABLED}
SMTP_SERVER: ${SMTP_SERVER}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM}
SMTP_USE_TLS: ${SMTP_USE_TLS}
SMTP_USE_SSL: ${SMTP_USE_SSL}
SMTP_TIMEOUT: ${SMTP_TIMEOUT}
PASSWORD_MIN_LENGTH: ${PASSWORD_MIN_LENGTH}
PASSWORD_MAX_LENGTH: ${PASSWORD_MAX_LENGTH}
PASSWORD_CHECK_NUMBER: ${PASSWORD_CHECK_NUMBER}
PASSWORD_CHECK_CHARAC: ${PASSWORD_CHECK_CHARAC}
PASSWORD_CHECK_CASES: ${PASSWORD_CHECK_CASES}
PASSWORD_CHECK_SYMBOL: ${PASSWORD_CHECK_SYMBOL}
PASSWORD_CHECK_LEAKED: ${PASSWORD_CHECK_LEAKED}
ports:
- "8080:8080"
networks:
- easywish-network
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
networks:
- easywish-network
minio:
image: minio/minio
healthcheck:
test: [ "CMD", "curl", "-I", "localhost:9000/minio/health/live" ]
interval: 5s
timeout: 10s
retries: 3
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
ports:
- "9000:9000"
command: server /data
networks:
- easywish-network
volumes:
- minio_data:/data
postgres:
image: postgres:latest
healthcheck:
test: [ "CMD", "pg_isready", "-q", "-d", "${POSTGRES_DB}", "-U", "${POSTGRES_USER}" ]
interval: 5s
timeout: 10s
retries: 3
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
networks:
- easywish-network
volumes:
- postgres_data:/var/lib/postgresql/data
- ./sqlc/schema.sql:/docker-entrypoint-initdb.d/init.sql
redis:
image: eqalpha/keydb
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 5s
timeout: 10s
retries: 3
command: ["keydb-server", "--requirepass", "${REDIS_PASSWORD}"]
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
ports:
- "6379:6379"
networks:
- easywish-network
test-mailserver:
image: rnwood/smtp4dev
hostname: easywish.weirdcatland
ports:
- "25:25"
- "5000:80"
networks:
- easywish-network
volumes:
postgres_data:
minio_data:
networks:
easywish-network:
driver: bridge

View File

@@ -56,6 +56,29 @@ SELECT users.* FROM users
JOIN login_informations linfo ON linfo.user_id = users.id
WHERE linfo.email = @email::text;
;-- name: CheckUserRegistrationAvailability :one
-- SELECT
-- COUNT(users.username = @username::text) > 0 AS username_busy,
-- COUNT(linfo.email = @email::text) > 0 AS email_busy
-- FROM users
-- JOIN login_informations AS linfo on linfo.user_id = users.id
-- WHERE
-- (
-- users.username = @username::text OR
-- linfo.email = @email::text
-- )
-- AND
-- (
-- users.verified IS TRUE OR
-- COUNT(
-- SELECT confirmation_codes as codes
-- JOIN users on users.id = codes.user_id
-- WHERE codes.code_type = 0 AND
-- codes.deleted IS FALSE AND
-- codes.expires_at < CURRENT_TIMESTAMP
-- ) = 0;
-- )
;-- name: GetValidUserByLoginCredentials :one
SELECT
users.id,
@@ -72,6 +95,46 @@ WHERE
banned.user_id IS NULL AND -- Not banned
linfo.password_hash = crypt(@password::text, linfo.password_hash); -- Password hash matches
;-- name: CheckUserRegistrationAvailability :one
SELECT
COUNT(CASE WHEN users.username = @username::text THEN 1 END) > 0 AS username_busy,
COUNT(CASE WHEN linfo.email = @email::text THEN 1 END) > 0 AS email_busy
FROM users
JOIN login_informations AS linfo ON linfo.user_id = users.id
WHERE
(
users.username = @username::text OR
linfo.email = @email::text
)
AND
(
users.verified IS TRUE OR
NOT EXISTS (
SELECT 1
FROM confirmation_codes AS codes
WHERE codes.user_id = users.id
AND codes.code_type = 0
AND codes.deleted IS FALSE
AND codes.expires_at > CURRENT_TIMESTAMP
)
);
;-- name: DeleteUnverifiedAccountsHavingUsernameOrEmail :one
WITH deleted_rows AS (
DELETE FROM users
WHERE
(username = @username::text OR
EXISTS (
SELECT 1
FROM login_informations AS linfo
WHERE linfo.user_id = users.id
AND linfo.email = @email::text
))
AND verified IS FALSE
RETURNING *
)
SELECT COUNT(*) AS deleted_count FROM deleted_rows;
--: }}}
--: Banned User Object {{{
@@ -111,13 +174,7 @@ VALUES ( $1, $2, @password_hash::text ) RETURNING *;
UPDATE login_informations
SET
email = COALESCE($2, email),
password_hash = COALESCE(
CASE
WHEN @password::text IS NOT NULL
THEN crypt(@password::text, gen_salt('bf'))
END,
password_hash
),
password_hash = COALESCE(@password_hash::text, password_hash),
totp_encrypted = COALESCE($4, totp_encrypted),
email_2fa_enabled = COALESCE($5, email_2fa_enabled),
password_change_date = COALESCE($6, password_change_date)
@@ -146,6 +203,15 @@ WHERE
used IS FALSE AND
code_hash = crypt(@code::text, code_hash);
;-- name: GetValidConfirmationCodesByUsername :many
SELECT * FROM confirmation_codes
JOIN users on users.id = confirmation_codes.user_id
WHERE
users.username = @username::text AND
code_type = @code_type::integer AND
expires_at > CURRENT_TIMESTAMP AND
used IS FALSE;
;-- name: UpdateConfirmationCode :exec
UPDATE confirmation_codes
SET