Compare commits
15 Commits
cbcfb8a286
...
15c140db31
| Author | SHA1 | Date | |
|---|---|---|---|
| 15c140db31 | |||
| 63b63038d1 | |||
| b5fdcd5dca | |||
| 72a512bb4f | |||
| 8588a17928 | |||
| bc9f5c6d3c | |||
| 333817c9e1 | |||
| 5e32c3cbd3 | |||
| 8319afc7ea | |||
| 0a51727af8 | |||
| d08db300fc | |||
| 96e41efdec | |||
| 284d959bc3 | |||
| e2d83aa779 | |||
| 69d55ce060 |
@@ -33,6 +33,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -46,6 +47,7 @@ import (
|
|||||||
"easywish/internal/logger"
|
"easywish/internal/logger"
|
||||||
"easywish/internal/routes"
|
"easywish/internal/routes"
|
||||||
"easywish/internal/services"
|
"easywish/internal/services"
|
||||||
|
"easywish/internal/validation"
|
||||||
|
|
||||||
swaggerfiles "github.com/swaggo/files"
|
swaggerfiles "github.com/swaggo/files"
|
||||||
ginSwagger "github.com/swaggo/gin-swagger"
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
@@ -65,6 +67,8 @@ func main() {
|
|||||||
),
|
),
|
||||||
database.Module,
|
database.Module,
|
||||||
services.Module,
|
services.Module,
|
||||||
|
validation.Module,
|
||||||
|
|
||||||
controllers.Module,
|
controllers.Module,
|
||||||
routes.Module,
|
routes.Module,
|
||||||
|
|
||||||
@@ -76,7 +80,7 @@ func main() {
|
|||||||
|
|
||||||
// Gin
|
// Gin
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%s", config.GetConfig().Port),
|
Addr: fmt.Sprintf(":%s", strconv.Itoa(int(config.GetConfig().Port))),
|
||||||
Handler: router,
|
Handler: router,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Hostname string `mapstructure:"HOSTNAME"`
|
Hostname string `mapstructure:"HOSTNAME"`
|
||||||
Port string `mapstructure:"PORT"`
|
Port uint16 `mapstructure:"PORT"`
|
||||||
|
|
||||||
DatabaseUrl string `mapstructure:"POSTGRES_URL"`
|
DatabaseUrl string `mapstructure:"POSTGRES_URL"`
|
||||||
RedisUrl string `mapstructure:"REDIS_URL"`
|
RedisUrl string `mapstructure:"REDIS_URL"`
|
||||||
@@ -39,8 +39,20 @@ type Config struct {
|
|||||||
JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"`
|
JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"`
|
||||||
JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"`
|
JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"`
|
||||||
|
|
||||||
PasswordCheckLength bool `mapstructure:"PASSWORD_CHECK_LENGTH"`
|
SmtpEnabled bool `mapstructure:"SMTP_ENABLED"`
|
||||||
|
SmtpServer string `mapstructure:"SMTP_SERVER"`
|
||||||
|
SmtpPort uint16 `mapstructure:"SMTP_PORT"`
|
||||||
|
SmtpUser string `mapstructure:"SMTP_USER"`
|
||||||
|
SmtpPassword string `mapstructure:"SMTP_PASSWORD"`
|
||||||
|
SmtpFrom string `mapstructure:"SMTP_FROM"`
|
||||||
|
SmtpUseTLS bool `mapstructure:"SMTP_USE_TLS"`
|
||||||
|
SmtpUseSSL bool `mapstructure:"SMTP_USE_SSL"`
|
||||||
|
SmtpTimeout uint `mapstructure:"SMTP_TIMEOUT"`
|
||||||
|
|
||||||
|
PasswordMinLength int `mapstructure:"PASSWORD_MIN_LENGTH"`
|
||||||
|
PasswordMaxLength int `mapstructure:"PASSWORD_MAX_LENGTH"`
|
||||||
PasswordCheckNumbers bool `mapstructure:"PASSWORD_CHECK_NUMBERS"`
|
PasswordCheckNumbers bool `mapstructure:"PASSWORD_CHECK_NUMBERS"`
|
||||||
|
PasswordCheckCharacters bool `mapstructure:"PASSWORD_CHECK_CHARACTERS"`
|
||||||
PasswordCheckCases bool `mapstructure:"PASSWORD_CHECK_CASES"`
|
PasswordCheckCases bool `mapstructure:"PASSWORD_CHECK_CASES"`
|
||||||
PasswordCheckSymbols bool `mapstructure:"PASSWORD_CHECK_SYMBOLS"`
|
PasswordCheckSymbols bool `mapstructure:"PASSWORD_CHECK_SYMBOLS"`
|
||||||
PasswordCheckLeaked bool `mapstructure:"PASSWORD_CHECK_LEAKED"`
|
PasswordCheckLeaked bool `mapstructure:"PASSWORD_CHECK_LEAKED"`
|
||||||
@@ -60,8 +72,15 @@ func Load() (*Config, error) {
|
|||||||
viper.SetDefault("JWT_AUDIENCE", "easywish")
|
viper.SetDefault("JWT_AUDIENCE", "easywish")
|
||||||
viper.SetDefault("JWT_ISSUER", "easywish")
|
viper.SetDefault("JWT_ISSUER", "easywish")
|
||||||
|
|
||||||
viper.SetDefault("PASSWORD_CHECK_LENGTH", true)
|
viper.SetDefault("SMTP_ENABLED", false)
|
||||||
|
viper.SetDefault("SMTP_USE_TLS", false)
|
||||||
|
viper.SetDefault("SMTP_USE_SSL", false)
|
||||||
|
viper.SetDefault("SMTP_FROM", "An Easywish instance")
|
||||||
|
|
||||||
|
viper.SetDefault("PASSWORD_MIN_LENGTH", 6)
|
||||||
|
viper.SetDefault("PASSWORD_MAX_LENGTH", 100)
|
||||||
viper.SetDefault("PASSWORD_CHECK_NUMBERS", false)
|
viper.SetDefault("PASSWORD_CHECK_NUMBERS", false)
|
||||||
|
viper.SetDefault("PASSWORD_CHECK_CHARACTERS", false)
|
||||||
viper.SetDefault("PASSWORD_CHECK_CASES", false)
|
viper.SetDefault("PASSWORD_CHECK_CASES", false)
|
||||||
viper.SetDefault("PASSWORD_CHECK_SYMBOLS", false)
|
viper.SetDefault("PASSWORD_CHECK_SYMBOLS", false)
|
||||||
viper.SetDefault("PASSWORD_CHECK_LEAKED", false)
|
viper.SetDefault("PASSWORD_CHECK_LEAKED", false)
|
||||||
@@ -89,8 +108,20 @@ func Load() (*Config, error) {
|
|||||||
viper.BindEnv("JWT_EXP_ACCESS")
|
viper.BindEnv("JWT_EXP_ACCESS")
|
||||||
viper.BindEnv("JWT_EXP_REFRESH")
|
viper.BindEnv("JWT_EXP_REFRESH")
|
||||||
|
|
||||||
viper.BindEnv("PASSWORD_CHECK_LENGTH")
|
viper.BindEnv("SMTP_ENABLED")
|
||||||
|
viper.BindEnv("SMTP_SERVER")
|
||||||
|
viper.BindEnv("SMTP_PORT")
|
||||||
|
viper.BindEnv("SMTP_USER")
|
||||||
|
viper.BindEnv("SMTP_PASSWORD")
|
||||||
|
viper.BindEnv("SMTP_FROM")
|
||||||
|
viper.BindEnv("SMTP_USE_TLS")
|
||||||
|
viper.BindEnv("SMTP_USE_SSL")
|
||||||
|
viper.BindEnv("SMTP_TIMEOUT")
|
||||||
|
|
||||||
|
viper.BindEnv("PASSWORD_MIN_LENGTH")
|
||||||
|
viper.BindEnv("PASSWORD_MAX_LENGTH")
|
||||||
viper.BindEnv("PASSWORD_CHECK_NUMBERS")
|
viper.BindEnv("PASSWORD_CHECK_NUMBERS")
|
||||||
|
viper.BindEnv("PASSWORD_CHECK_CHARACTERS")
|
||||||
viper.BindEnv("PASSWORD_CHECK_CASES")
|
viper.BindEnv("PASSWORD_CHECK_CASES")
|
||||||
viper.BindEnv("PASSWORD_CHECK_SYMBOLS")
|
viper.BindEnv("PASSWORD_CHECK_SYMBOLS")
|
||||||
viper.BindEnv("PASSWORD_CHECK_LEAKED")
|
viper.BindEnv("PASSWORD_CHECK_LEAKED")
|
||||||
@@ -122,7 +153,7 @@ func Load() (*Config, error) {
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfig() *Config {
|
func GetConfig() Config {
|
||||||
|
|
||||||
if config == nil {
|
if config == nil {
|
||||||
|
|
||||||
@@ -131,7 +162,7 @@ func GetConfig() *Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return *config
|
||||||
}
|
}
|
||||||
|
|
||||||
var config *Config
|
var config *Config
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const docTemplate = `{
|
|||||||
"title": "{{.Title}}",
|
"title": "{{.Title}}",
|
||||||
"contact": {},
|
"contact": {},
|
||||||
"license": {
|
"license": {
|
||||||
"name": "GPL 3.0"
|
"name": "GPL-3.0"
|
||||||
},
|
},
|
||||||
"version": "{{.Version}}"
|
"version": "{{.Version}}"
|
||||||
},
|
},
|
||||||
@@ -139,7 +139,14 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {}
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Account is created and awaiting verification"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Username or email is already taken"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/auth/registrationComplete": {
|
"/auth/registrationComplete": {
|
||||||
@@ -154,7 +161,25 @@ const docTemplate = `{
|
|||||||
"Auth"
|
"Auth"
|
||||||
],
|
],
|
||||||
"summary": "Confirm with code, finish creating the account",
|
"summary": "Confirm with code, finish creating the account",
|
||||||
"responses": {}
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "desc",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.RegistrationCompleteRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "desc",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.RegistrationCompleteResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/profile": {
|
"/profile": {
|
||||||
@@ -305,13 +330,16 @@ const docTemplate = `{
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"maxLength": 100
|
||||||
},
|
},
|
||||||
"totp": {
|
"totp": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"maxLength": 20,
|
||||||
|
"minLength": 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -337,13 +365,43 @@ const docTemplate = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"description": "TODO: password checking",
|
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"maxLength": 20,
|
}
|
||||||
"minLength": 3
|
}
|
||||||
|
},
|
||||||
|
"models.RegistrationCompleteRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"username",
|
||||||
|
"verification_code"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"birthday": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"verification_code": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models.RegistrationCompleteResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"access_token": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"title": "Easywish client API",
|
"title": "Easywish client API",
|
||||||
"contact": {},
|
"contact": {},
|
||||||
"license": {
|
"license": {
|
||||||
"name": "GPL 3.0"
|
"name": "GPL-3.0"
|
||||||
},
|
},
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
},
|
},
|
||||||
@@ -135,7 +135,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {}
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Account is created and awaiting verification"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Username or email is already taken"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/auth/registrationComplete": {
|
"/auth/registrationComplete": {
|
||||||
@@ -150,7 +157,25 @@
|
|||||||
"Auth"
|
"Auth"
|
||||||
],
|
],
|
||||||
"summary": "Confirm with code, finish creating the account",
|
"summary": "Confirm with code, finish creating the account",
|
||||||
"responses": {}
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "desc",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.RegistrationCompleteRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "desc",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/models.RegistrationCompleteResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/profile": {
|
"/profile": {
|
||||||
@@ -301,13 +326,16 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"maxLength": 100
|
||||||
},
|
},
|
||||||
"totp": {
|
"totp": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"maxLength": 20,
|
||||||
|
"minLength": 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -333,13 +361,43 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"description": "TODO: password checking",
|
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"maxLength": 20,
|
}
|
||||||
"minLength": 3
|
}
|
||||||
|
},
|
||||||
|
"models.RegistrationCompleteRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"username",
|
||||||
|
"verification_code"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"birthday": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"verification_code": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models.RegistrationCompleteResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"access_token": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ definitions:
|
|||||||
models.LoginRequest:
|
models.LoginRequest:
|
||||||
properties:
|
properties:
|
||||||
password:
|
password:
|
||||||
|
maxLength: 100
|
||||||
type: string
|
type: string
|
||||||
totp:
|
totp:
|
||||||
type: string
|
type: string
|
||||||
username:
|
username:
|
||||||
|
maxLength: 20
|
||||||
|
minLength: 3
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- password
|
- password
|
||||||
@@ -29,21 +32,40 @@ definitions:
|
|||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
password:
|
password:
|
||||||
description: 'TODO: password checking'
|
|
||||||
type: string
|
type: string
|
||||||
username:
|
username:
|
||||||
maxLength: 20
|
|
||||||
minLength: 3
|
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- password
|
- password
|
||||||
- username
|
- username
|
||||||
type: object
|
type: object
|
||||||
|
models.RegistrationCompleteRequest:
|
||||||
|
properties:
|
||||||
|
birthday:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
verification_code:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- username
|
||||||
|
- verification_code
|
||||||
|
type: object
|
||||||
|
models.RegistrationCompleteResponse:
|
||||||
|
properties:
|
||||||
|
access_token:
|
||||||
|
type: string
|
||||||
|
refresh_token:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
description: Easy and feature-rich wishlist.
|
description: Easy and feature-rich wishlist.
|
||||||
license:
|
license:
|
||||||
name: GPL 3.0
|
name: GPL-3.0
|
||||||
title: Easywish client API
|
title: Easywish client API
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
paths:
|
paths:
|
||||||
@@ -124,7 +146,11 @@ paths:
|
|||||||
$ref: '#/definitions/models.RegistrationBeginRequest'
|
$ref: '#/definitions/models.RegistrationBeginRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses: {}
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Account is created and awaiting verification
|
||||||
|
"409":
|
||||||
|
description: Username or email is already taken
|
||||||
summary: Register an account
|
summary: Register an account
|
||||||
tags:
|
tags:
|
||||||
- Auth
|
- Auth
|
||||||
@@ -132,9 +158,20 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: desc
|
||||||
|
in: body
|
||||||
|
name: request
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.RegistrationCompleteRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses: {}
|
responses:
|
||||||
|
"200":
|
||||||
|
description: desc
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/models.RegistrationCompleteResponse'
|
||||||
summary: Confirm with code, finish creating the account
|
summary: Confirm with code, finish creating the account
|
||||||
tags:
|
tags:
|
||||||
- Auth
|
- Auth
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ go 1.24.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/jackc/pgx/v5 v5.7.5
|
github.com/jackc/pgx/v5 v5.7.5
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/stretchr/testify v1.10.0
|
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
github.com/swaggo/gin-swagger v1.6.0
|
github.com/swaggo/gin-swagger v1.6.0
|
||||||
github.com/swaggo/swag v1.16.4
|
github.com/swaggo/swag v1.16.4
|
||||||
@@ -20,7 +20,6 @@ require (
|
|||||||
github.com/bytedance/sonic v1.13.3 // indirect
|
github.com/bytedance/sonic v1.13.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
@@ -30,9 +29,9 @@ require (
|
|||||||
github.com/go-openapi/swag v0.23.1 // indirect
|
github.com/go-openapi/swag v0.23.1 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
@@ -45,7 +44,6 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.14.0 // indirect
|
github.com/spf13/afero v1.14.0 // indirect
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
|||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
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/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
@@ -48,6 +48,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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
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=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
|||||||
@@ -18,11 +18,13 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
errs "easywish/internal/errors"
|
||||||
"easywish/internal/middleware"
|
"easywish/internal/middleware"
|
||||||
"easywish/internal/models"
|
"easywish/internal/models"
|
||||||
"easywish/internal/services"
|
"easywish/internal/services"
|
||||||
"easywish/internal/utils"
|
"easywish/internal/utils"
|
||||||
"easywish/internal/utils/enums"
|
"easywish/internal/utils/enums"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -48,7 +50,6 @@ func NewAuthController(_log *zap.Logger, as services.AuthService) AuthController
|
|||||||
return &authControllerImpl{log: _log, authService: as}
|
return &authControllerImpl{log: _log, authService: as}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login implements AuthController.
|
|
||||||
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
|
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -57,10 +58,27 @@ func NewAuthController(_log *zap.Logger, as services.AuthService) AuthController
|
|||||||
// @Success 200 {object} models.LoginResponse "desc"
|
// @Success 200 {object} models.LoginResponse "desc"
|
||||||
// @Router /auth/login [post]
|
// @Router /auth/login [post]
|
||||||
func (a *authControllerImpl) Login(c *gin.Context) {
|
func (a *authControllerImpl) Login(c *gin.Context) {
|
||||||
c.Status(http.StatusNotImplemented)
|
request, ok := utils.GetRequest[models.LoginRequest](c)
|
||||||
|
if !ok {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := a.authService.Login(request.Body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errs.ErrForbidden) {
|
||||||
|
c.Status(http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasswordResetBegin implements AuthController.
|
|
||||||
// @Summary Request password reset email
|
// @Summary Request password reset email
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -70,7 +88,6 @@ func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
|
|||||||
c.Status(http.StatusNotImplemented)
|
c.Status(http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasswordResetComplete implements AuthController.
|
|
||||||
// @Summary Complete password reset with email code and provide 2FA code or backup code if needed
|
// @Summary Complete password reset with email code and provide 2FA code or backup code if needed
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -80,7 +97,6 @@ func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
|
|||||||
c.Status(http.StatusNotImplemented)
|
c.Status(http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh implements AuthController.
|
|
||||||
// @Summary Receive new tokens via refresh token
|
// @Summary Receive new tokens via refresh token
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -90,12 +106,13 @@ func (a *authControllerImpl) Refresh(c *gin.Context) {
|
|||||||
c.Status(http.StatusNotImplemented)
|
c.Status(http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistrationComplete implements AuthController.
|
|
||||||
// @Summary Register an account
|
// @Summary Register an account
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body models.RegistrationBeginRequest true "desc"
|
// @Param request body models.RegistrationBeginRequest true "desc"
|
||||||
|
// @Success 200 "Account is created and awaiting verification"
|
||||||
|
// @Success 409 "Username or email is already taken"
|
||||||
// @Router /auth/registrationBegin [post]
|
// @Router /auth/registrationBegin [post]
|
||||||
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
|
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
|
||||||
|
|
||||||
@@ -108,29 +125,53 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
|
|||||||
_, err := a.authService.RegistrationBegin(request.Body)
|
_, err := a.authService.RegistrationBegin(request.Body)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, err.Error())
|
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
|
||||||
|
c.Status(http.StatusConflict)
|
||||||
|
} else {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Status(http.StatusAccepted)
|
c.Status(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistrationBegin implements AuthController.
|
|
||||||
// @Summary Confirm with code, finish creating the account
|
// @Summary Confirm with code, finish creating the account
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
// @Param request body models.RegistrationCompleteRequest true "desc"
|
||||||
|
// @Success 200 {object} models.RegistrationCompleteResponse "desc"
|
||||||
// @Router /auth/registrationComplete [post]
|
// @Router /auth/registrationComplete [post]
|
||||||
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
|
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
|
||||||
c.Status(http.StatusNotImplemented)
|
request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c)
|
||||||
|
if !ok {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := a.authService.RegistrationComplete(request.Body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errs.ErrForbidden) {
|
||||||
|
c.Status(http.StatusForbidden)
|
||||||
|
} else if errors.Is(err, errs.ErrUnauthorized) {
|
||||||
|
c.Status(http.StatusUnauthorized)
|
||||||
|
} else {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
|
func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
|
||||||
group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin)
|
group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin)
|
||||||
group.POST("/registrationComplete", a.RegistrationComplete)
|
group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete)
|
||||||
group.POST("/login", a.Login)
|
group.POST("/login", middleware.RequestMiddleware[models.LoginRequest](enums.GuestRole), a.Login)
|
||||||
group.POST("/refresh", a.Refresh)
|
group.POST("/refresh", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.UserRole), a.Refresh)
|
||||||
group.POST("/passwordResetBegin", a.PasswordResetBegin)
|
group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetBegin)
|
||||||
group.POST("/passwordResetComplete", a.PasswordResetComplete)
|
group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetComplete)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,17 +46,17 @@ func (q *Queries) CreateBannedUser(ctx context.Context, arg CreateBannedUserPara
|
|||||||
|
|
||||||
const createConfirmationCode = `-- name: CreateConfirmationCode :one
|
const createConfirmationCode = `-- name: CreateConfirmationCode :one
|
||||||
INSERT INTO confirmation_codes(user_id, code_type, code_hash)
|
INSERT INTO confirmation_codes(user_id, code_type, code_hash)
|
||||||
VALUES ($1, $2, crypt($3::text, gen_salt('bf'))) RETURNING id, user_id, code_type, code_hash, expires_at, used, deleted
|
VALUES ($1, $2, $3) RETURNING id, user_id, code_type, code_hash, expires_at, used, deleted
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateConfirmationCodeParams struct {
|
type CreateConfirmationCodeParams struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
CodeType int32
|
CodeType int32
|
||||||
Code string
|
CodeHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateConfirmationCode(ctx context.Context, arg CreateConfirmationCodeParams) (ConfirmationCode, error) {
|
func (q *Queries) CreateConfirmationCode(ctx context.Context, arg CreateConfirmationCodeParams) (ConfirmationCode, error) {
|
||||||
row := q.db.QueryRow(ctx, createConfirmationCode, arg.UserID, arg.CodeType, arg.Code)
|
row := q.db.QueryRow(ctx, createConfirmationCode, arg.UserID, arg.CodeType, arg.CodeHash)
|
||||||
var i ConfirmationCode
|
var i ConfirmationCode
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
@@ -72,17 +72,17 @@ func (q *Queries) CreateConfirmationCode(ctx context.Context, arg CreateConfirma
|
|||||||
|
|
||||||
const createLoginInformation = `-- name: CreateLoginInformation :one
|
const createLoginInformation = `-- name: CreateLoginInformation :one
|
||||||
INSERT INTO login_informations(user_id, email, password_hash)
|
INSERT INTO login_informations(user_id, email, password_hash)
|
||||||
VALUES ( $1, $2, crypt($3::text, gen_salt('bf')) ) RETURNING id, user_id, email, password_hash, totp_encrypted, email_2fa_enabled, password_change_date
|
VALUES ( $1, $2, $3::text ) RETURNING id, user_id, email, password_hash, totp_encrypted, email_2fa_enabled, password_change_date
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateLoginInformationParams struct {
|
type CreateLoginInformationParams struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
Email *string
|
Email *string
|
||||||
Password string
|
PasswordHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateLoginInformation(ctx context.Context, arg CreateLoginInformationParams) (LoginInformation, error) {
|
func (q *Queries) CreateLoginInformation(ctx context.Context, arg CreateLoginInformationParams) (LoginInformation, error) {
|
||||||
row := q.db.QueryRow(ctx, createLoginInformation, arg.UserID, arg.Email, arg.Password)
|
row := q.db.QueryRow(ctx, createLoginInformation, arg.UserID, arg.Email, arg.PasswordHash)
|
||||||
var i LoginInformation
|
var i LoginInformation
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
@@ -229,32 +229,6 @@ func (q *Queries) DeleteUserByUsername(ctx context.Context, username string) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getConfirmationCodeByCode = `-- name: GetConfirmationCodeByCode :one
|
|
||||||
SELECT id, user_id, code_type, code_hash, expires_at, used, deleted FROM confirmation_codes
|
|
||||||
WHERE user_id = $1 AND code_type = $2 AND expires_at > CURRENT_TIMESTAMP AND code_hash = crypt($3, code_hash)
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetConfirmationCodeByCodeParams struct {
|
|
||||||
UserID int64
|
|
||||||
CodeType int32
|
|
||||||
Crypt string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetConfirmationCodeByCode(ctx context.Context, arg GetConfirmationCodeByCodeParams) (ConfirmationCode, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getConfirmationCodeByCode, arg.UserID, arg.CodeType, arg.Crypt)
|
|
||||||
var i ConfirmationCode
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.UserID,
|
|
||||||
&i.CodeType,
|
|
||||||
&i.CodeHash,
|
|
||||||
&i.ExpiresAt,
|
|
||||||
&i.Used,
|
|
||||||
&i.Deleted,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLoginInformationByUsername = `-- name: GetLoginInformationByUsername :one
|
const getLoginInformationByUsername = `-- name: GetLoginInformationByUsername :one
|
||||||
SELECT login_informations.id, login_informations.user_id, login_informations.email, login_informations.password_hash, login_informations.totp_encrypted, login_informations.email_2fa_enabled, login_informations.password_change_date FROM login_informations
|
SELECT login_informations.id, login_informations.user_id, login_informations.email, login_informations.password_hash, login_informations.totp_encrypted, login_informations.email_2fa_enabled, login_informations.password_change_date FROM login_informations
|
||||||
JOIN users ON users.id = login_informations.user_id
|
JOIN users ON users.id = login_informations.user_id
|
||||||
@@ -526,43 +500,21 @@ func (q *Queries) GetUserBansByUsername(ctx context.Context, username string) ([
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByLoginCredentials = `-- name: GetUserByLoginCredentials :one
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||||
SELECT
|
SELECT users.id, users.username, users.verified, users.registration_date, users.deleted FROM users
|
||||||
users.id,
|
JOIN login_informations linfo ON linfo.user_id = users.id
|
||||||
users.username,
|
WHERE linfo.email = $1::text
|
||||||
linfo.password_hash,
|
|
||||||
linfo.totp_encrypted
|
|
||||||
FROM users
|
|
||||||
JOIN login_informations AS linfo ON users.id = linfo.user_id
|
|
||||||
LEFT JOIN banned_users AS banned ON users.id = banned.user_id
|
|
||||||
WHERE
|
|
||||||
users.username = $1 AND
|
|
||||||
users.verified IS TRUE AND -- Verified
|
|
||||||
users.deleted IS FALSE AND -- Not deleted
|
|
||||||
banned.user_id IS NULL AND -- Not banned
|
|
||||||
linfo.password_hash = crypt($2::text, linfo.password_hash)
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetUserByLoginCredentialsParams struct {
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
Username string
|
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
||||||
Password string
|
var i User
|
||||||
}
|
|
||||||
|
|
||||||
type GetUserByLoginCredentialsRow struct {
|
|
||||||
ID int64
|
|
||||||
Username string
|
|
||||||
PasswordHash string
|
|
||||||
TotpEncrypted *string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetUserByLoginCredentials(ctx context.Context, arg GetUserByLoginCredentialsParams) (GetUserByLoginCredentialsRow, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getUserByLoginCredentials, arg.Username, arg.Password)
|
|
||||||
var i GetUserByLoginCredentialsRow
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Username,
|
&i.Username,
|
||||||
&i.PasswordHash,
|
&i.Verified,
|
||||||
&i.TotpEncrypted,
|
&i.RegistrationDate,
|
||||||
|
&i.Deleted,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -620,6 +572,78 @@ func (q *Queries) GetUserSessions(ctx context.Context, userID int64) ([]Session,
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getValidConfirmationCodeByCode = `-- name: GetValidConfirmationCodeByCode :one
|
||||||
|
SELECT id, user_id, code_type, code_hash, expires_at, used, deleted FROM confirmation_codes
|
||||||
|
WHERE
|
||||||
|
user_id = $1 AND
|
||||||
|
code_type = $2 AND
|
||||||
|
expires_at > CURRENT_TIMESTAMP AND
|
||||||
|
used IS FALSE AND
|
||||||
|
code_hash = crypt($3::text, code_hash)
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetValidConfirmationCodeByCodeParams struct {
|
||||||
|
UserID int64
|
||||||
|
CodeType int32
|
||||||
|
Code string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetValidConfirmationCodeByCode(ctx context.Context, arg GetValidConfirmationCodeByCodeParams) (ConfirmationCode, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getValidConfirmationCodeByCode, arg.UserID, arg.CodeType, arg.Code)
|
||||||
|
var i ConfirmationCode
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CodeType,
|
||||||
|
&i.CodeHash,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.Used,
|
||||||
|
&i.Deleted,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getValidUserByLoginCredentials = `-- name: GetValidUserByLoginCredentials :one
|
||||||
|
SELECT
|
||||||
|
users.id,
|
||||||
|
users.username,
|
||||||
|
linfo.password_hash,
|
||||||
|
linfo.totp_encrypted
|
||||||
|
FROM users
|
||||||
|
JOIN login_informations AS linfo ON users.id = linfo.user_id
|
||||||
|
LEFT JOIN banned_users AS banned ON users.id = banned.user_id
|
||||||
|
WHERE
|
||||||
|
users.username = $1 AND
|
||||||
|
users.verified IS TRUE AND -- Verified
|
||||||
|
users.deleted IS FALSE AND -- Not deleted
|
||||||
|
banned.user_id IS NULL AND -- Not banned
|
||||||
|
linfo.password_hash = crypt($2::text, linfo.password_hash)
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetValidUserByLoginCredentialsParams struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetValidUserByLoginCredentialsRow struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
PasswordHash string
|
||||||
|
TotpEncrypted *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetValidUserByLoginCredentials(ctx context.Context, arg GetValidUserByLoginCredentialsParams) (GetValidUserByLoginCredentialsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getValidUserByLoginCredentials, arg.Username, arg.Password)
|
||||||
|
var i GetValidUserByLoginCredentialsRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.TotpEncrypted,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const pruneExpiredConfirmationCodes = `-- name: PruneExpiredConfirmationCodes :exec
|
const pruneExpiredConfirmationCodes = `-- name: PruneExpiredConfirmationCodes :exec
|
||||||
DELETE FROM confirmation_codes
|
DELETE FROM confirmation_codes
|
||||||
WHERE expires_at < CURRENT_TIMESTAMP
|
WHERE expires_at < CURRENT_TIMESTAMP
|
||||||
@@ -640,9 +664,26 @@ func (q *Queries) PruneTerminatedSessions(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const terminateAllSessionsForUserByUsername = `-- name: TerminateAllSessionsForUserByUsername :exec
|
||||||
|
UPDATE sessions
|
||||||
|
SET terminated = TRUE
|
||||||
|
FROM users
|
||||||
|
WHERE sessions.user_id = users.id AND users.username = $1::text
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TerminateAllSessionsForUserByUsername(ctx context.Context, username string) error {
|
||||||
|
_, err := q.db.Exec(ctx, terminateAllSessionsForUserByUsername, username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const updateBannedUser = `-- name: UpdateBannedUser :exec
|
const updateBannedUser = `-- name: UpdateBannedUser :exec
|
||||||
UPDATE banned_users
|
UPDATE banned_users
|
||||||
SET reason = $2, expires_at = $3, banned_by = $4, pardoned = $5, pardoned_by = $6
|
SET
|
||||||
|
reason = COALESCE($2, reason),
|
||||||
|
expires_at = COALESCE($3, expires_at),
|
||||||
|
banned_by = COALESCE($4, banned_by),
|
||||||
|
pardoned = COALESCE($5, pardoned),
|
||||||
|
pardoned_by = COALESCE($6, pardoned_by)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -669,7 +710,9 @@ func (q *Queries) UpdateBannedUser(ctx context.Context, arg UpdateBannedUserPara
|
|||||||
|
|
||||||
const updateConfirmationCode = `-- name: UpdateConfirmationCode :exec
|
const updateConfirmationCode = `-- name: UpdateConfirmationCode :exec
|
||||||
UPDATE confirmation_codes
|
UPDATE confirmation_codes
|
||||||
SET used = $2, deleted = $3
|
SET
|
||||||
|
used = COALESCE($2, used),
|
||||||
|
deleted = COALESCE($3, deleted)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -686,7 +729,18 @@ func (q *Queries) UpdateConfirmationCode(ctx context.Context, arg UpdateConfirma
|
|||||||
|
|
||||||
const updateLoginInformationByUsername = `-- name: UpdateLoginInformationByUsername :exec
|
const updateLoginInformationByUsername = `-- name: UpdateLoginInformationByUsername :exec
|
||||||
UPDATE login_informations
|
UPDATE login_informations
|
||||||
SET email = $2, password_hash = crypt($3::text, gen_salt('bf')), totp_encrypted = $4, email_2fa_enabled = $5, password_change_date = $6
|
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
|
||||||
|
),
|
||||||
|
totp_encrypted = COALESCE($4, totp_encrypted),
|
||||||
|
email_2fa_enabled = COALESCE($5, email_2fa_enabled),
|
||||||
|
password_change_date = COALESCE($6, password_change_date)
|
||||||
FROM users
|
FROM users
|
||||||
WHERE users.username = $1 AND login_informations.user_id = users.id
|
WHERE users.username = $1 AND login_informations.user_id = users.id
|
||||||
`
|
`
|
||||||
@@ -714,7 +768,13 @@ func (q *Queries) UpdateLoginInformationByUsername(ctx context.Context, arg Upda
|
|||||||
|
|
||||||
const updateProfileByUsername = `-- name: UpdateProfileByUsername :exec
|
const updateProfileByUsername = `-- name: UpdateProfileByUsername :exec
|
||||||
UPDATE profiles
|
UPDATE profiles
|
||||||
SET name = $2, bio = $3, birthday = $4, avatar_url = $5, color = $6, color_grad = $7
|
SET
|
||||||
|
name = COALESCE($2, name),
|
||||||
|
bio = COALESCE($3, bio),
|
||||||
|
birthday = COALESCE($4, birthday),
|
||||||
|
avatar_url = COALESCE($5, avatar_url),
|
||||||
|
color = COALESCE($6, color),
|
||||||
|
color_grad = COALESCE($7, color_grad)
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = $1
|
WHERE username = $1
|
||||||
`
|
`
|
||||||
@@ -745,13 +805,13 @@ func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfile
|
|||||||
const updateProfileSettings = `-- name: UpdateProfileSettings :exec
|
const updateProfileSettings = `-- name: UpdateProfileSettings :exec
|
||||||
UPDATE profile_settings
|
UPDATE profile_settings
|
||||||
SET
|
SET
|
||||||
hide_fulfilled = $2,
|
hide_fulfilled = COALESCE($2, hide_fulfilled),
|
||||||
hide_profile_details = $3,
|
hide_profile_details = COALESCE($3, hide_profile_details),
|
||||||
hide_for_unauthenticated = $4,
|
hide_for_unauthenticated = COALESCE($4, hide_for_unauthenticated),
|
||||||
hide_birthday = $5,
|
hide_birthday = COALESCE($5, hide_birthday),
|
||||||
hide_dates = $6,
|
hide_dates = COALESCE($6, hide_dates),
|
||||||
captcha = $7,
|
captcha = COALESCE($7, captcha),
|
||||||
followers_only_interaction = $8
|
followers_only_interaction = COALESCE($8, followers_only_interaction)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -782,7 +842,13 @@ func (q *Queries) UpdateProfileSettings(ctx context.Context, arg UpdateProfileSe
|
|||||||
|
|
||||||
const updateSession = `-- name: UpdateSession :exec
|
const updateSession = `-- name: UpdateSession :exec
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET name = $2, platform = $3, latest_ip = $4, login_time = $5, last_seen_date = $6, terminated = $7
|
SET
|
||||||
|
name = COALESCE($2, name),
|
||||||
|
platform = COALESCE($3, platform),
|
||||||
|
latest_ip = COALESCE($4, latest_ip),
|
||||||
|
login_time = COALESCE($5, login_time),
|
||||||
|
last_seen_date = COALESCE($6, last_seen_date),
|
||||||
|
terminated = COALESCE($7, terminated)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -811,7 +877,9 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er
|
|||||||
|
|
||||||
const updateUser = `-- name: UpdateUser :exec
|
const updateUser = `-- name: UpdateUser :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET verified = $2, deleted = $3
|
SET
|
||||||
|
verified = COALESCE($2, verified),
|
||||||
|
deleted = COALESCE($3, deleted)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrUnauthorized = errors.New("User is not authorized")
|
ErrUnauthorized = errors.New("User is not authorized")
|
||||||
ErrUsernameTaken = errors.New("Provided username is already in use")
|
ErrUsernameTaken = errors.New("Provided username is already in use")
|
||||||
|
ErrEmailTaken = errors.New("Provided email is already in use")
|
||||||
|
ErrUserNotFound = errors.New("User was not found")
|
||||||
ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code")
|
ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code")
|
||||||
ErrInvalidToken = errors.New("Token is invalid or expired")
|
ErrInvalidToken = errors.New("Token is invalid or expired")
|
||||||
ErrServerError = errors.New("Internal server error")
|
ErrServerError = errors.New("Internal server error")
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrNotImplemented = errors.New("Feature is not implemented")
|
ErrNotImplemented = errors.New("Feature is not implemented")
|
||||||
ErrBadRequest = errors.New("Bad request")
|
ErrBadRequest = errors.New("Bad request")
|
||||||
|
ErrForbidden = errors.New("Access is denied")
|
||||||
)
|
)
|
||||||
|
|||||||
34
backend/internal/errors/postgres.go
Normal file
34
backend/internal/errors/postgres.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// 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 errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetPgError(err error) string {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
errors.As(err, &pgErr)
|
||||||
|
return pgErr.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchPgError(err error, code string) bool {
|
||||||
|
return GetPgError(err) == code
|
||||||
|
}
|
||||||
10
backend/internal/errors/smtp.go
Normal file
10
backend/internal/errors/smtp.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSmtpDisabled = errors.New("Smtp is not enabled in the config")
|
||||||
|
ErrSmtpMissingConfiguration = errors.New("Some necessary SMTP configuration is missing")
|
||||||
|
)
|
||||||
@@ -34,6 +34,8 @@ type Claims struct {
|
|||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: validate token type
|
||||||
|
// TODO: validate session guid
|
||||||
func AuthMiddleware() gin.HandlerFunc {
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"easywish/internal/utils/enums"
|
"easywish/internal/utils/enums"
|
||||||
|
"easywish/internal/validation"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserInfo struct {
|
type UserInfo struct {
|
||||||
@@ -77,13 +80,22 @@ func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc {
|
|||||||
|
|
||||||
if userInfo.Role < role {
|
if userInfo.Role < role {
|
||||||
c.Status(http.StatusForbidden)
|
c.Status(http.StatusForbidden)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body T
|
var body T
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.String(http.StatusBadRequest, err.Error())
|
c.String(http.StatusBadRequest, err.Error())
|
||||||
|
|
||||||
// TODO: implement automatic validation here
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validate := validation.NewValidator()
|
||||||
|
|
||||||
|
if err := validate.Struct(body); err != nil {
|
||||||
|
errorList := err.(validator.ValidationErrors)
|
||||||
|
c.String(http.StatusBadRequest, fmt.Sprintf("Validation error: %s", errorList))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,28 +23,25 @@ type Tokens struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RegistrationBeginRequest struct {
|
type RegistrationBeginRequest struct {
|
||||||
Username string `json:"username" binding:"required,min=3,max=20"`
|
Username string `json:"username" binding:"required" validate:"username"`
|
||||||
Email *string `json:"email" binding:"email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
Password string `json:"password" binding:"required"` // TODO: password checking
|
Password string `json:"password" binding:"required" validate:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: length check
|
|
||||||
type RegistrationCompleteRequest struct {
|
type RegistrationCompleteRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required" validate:"username"`
|
||||||
VerificationCode string `json:"verification_code" binding:"required"`
|
VerificationCode string `json:"verification_code" binding:"required"`
|
||||||
Name string `json:"name" binding:"required,max=75"`
|
Name string `json:"name" binding:"required" validate:"name"`
|
||||||
Birthday *string `json:"birthday"`
|
Birthday *string `json:"birthday"`
|
||||||
AvatarUrl *string `json:"avatar_url" binding:"http_url,max=255"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistrationCompleteResponse struct {
|
type RegistrationCompleteResponse struct {
|
||||||
Tokens
|
Tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: length check
|
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required,min=3,max=20" validate:"username"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required,max=100"`
|
||||||
TOTP *string `json:"totp"`
|
TOTP *string `json:"totp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,53 +18,81 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"easywish/config"
|
||||||
"easywish/internal/database"
|
"easywish/internal/database"
|
||||||
errs "easywish/internal/errors"
|
errs "easywish/internal/errors"
|
||||||
"easywish/internal/models"
|
"easywish/internal/models"
|
||||||
"easywish/internal/utils"
|
"easywish/internal/utils"
|
||||||
"easywish/internal/utils/enums"
|
"easywish/internal/utils/enums"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgerrcode"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthService interface {
|
type AuthService interface {
|
||||||
RegistrationBegin(request models.RegistrationBeginRequest) (bool, error)
|
RegistrationBegin(request models.RegistrationBeginRequest) (bool, error)
|
||||||
RegistrationComplete(request models.RegistrationBeginRequest) (*models.RegistrationCompleteResponse, error)
|
RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error)
|
||||||
Login(request models.LoginRequest) (*models.LoginResponse, error)
|
Login(request models.LoginRequest) (*models.LoginResponse, error)
|
||||||
Refresh(request models.RefreshRequest) (*models.RefreshResponse, error)
|
Refresh(request models.RefreshRequest) (*models.RefreshResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type authServiceImpl struct {
|
type authServiceImpl struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
|
smtp SmtpService
|
||||||
dbctx database.DbContext
|
dbctx database.DbContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(_log *zap.Logger, _dbctx database.DbContext) AuthService {
|
func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _smtp SmtpService) AuthService {
|
||||||
return &authServiceImpl{log: _log, dbctx: _dbctx}
|
return &authServiceImpl{log: _log, dbctx: _dbctx, smtp: _smtp}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) {
|
func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) {
|
||||||
|
|
||||||
var user database.User
|
var user database.User
|
||||||
var generatedCode string
|
var generatedCode string
|
||||||
|
var generatedCodeHash string
|
||||||
|
var passwordHash string
|
||||||
|
|
||||||
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
|
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
|
||||||
defer helper.Rollback()
|
defer helper.Rollback()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil { // TODO: validation
|
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),
|
||||||
|
zap.Error(err))
|
||||||
|
|
||||||
|
return false, errs.ErrUsernameTaken
|
||||||
|
}
|
||||||
|
|
||||||
a.log.Error("Failed to add user to database", zap.Error(err))
|
a.log.Error("Failed to add user to database", zap.Error(err))
|
||||||
return false, errs.ErrServerError
|
return false, errs.ErrServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
a.log.Info("Registraion of a new user", zap.String("username", user.Username), zap.Int64("id", user.ID))
|
if passwordHash, err = utils.HashPassword(request.Password); err != nil {
|
||||||
|
a.log.Error("Error on hashing password", zap.Error(err))
|
||||||
|
|
||||||
|
return false, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{
|
if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Email: request.Email,
|
Email: utils.NewPointer(request.Email),
|
||||||
Password: request.Password, // Hashed in database
|
PasswordHash: passwordHash, // Hashed in database
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|
||||||
|
if errs.MatchPgError(err, pgerrcode.UniqueViolation) {
|
||||||
|
// Since we've already checked for username previously, only email is left
|
||||||
|
return false, errs.ErrEmailTaken
|
||||||
|
}
|
||||||
|
|
||||||
a.log.Error("Failed to add login information for user to database", zap.Error(err))
|
a.log.Error("Failed to add login information for user to database", zap.Error(err))
|
||||||
return false, errs.ErrServerError
|
return false, errs.ErrServerError
|
||||||
}
|
}
|
||||||
@@ -73,52 +101,268 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
|
|||||||
a.log.Error("Failed to generate a registration code", zap.Error(err))
|
a.log.Error("Failed to generate a registration code", zap.Error(err))
|
||||||
return false, errs.ErrServerError
|
return false, errs.ErrServerError
|
||||||
}
|
}
|
||||||
|
if generatedCodeHash, err = utils.HashPassword(generatedCode); err != nil {
|
||||||
|
a.log.Error("Failed to hash generated verification code", zap.Error(err))
|
||||||
|
return false, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{
|
if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
CodeType: int32(enums.RegistrationCodeType),
|
CodeType: int32(enums.RegistrationCodeType),
|
||||||
Code: generatedCode, // Hashed in database
|
CodeHash: generatedCodeHash, // Hashed in database
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
a.log.Error("Failed to add registration code to database", zap.Error(err))
|
a.log.Error("Failed to add registration code to database", zap.Error(err))
|
||||||
return false, errs.ErrServerError
|
return false, errs.ErrServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
a.log.Info("Registered a new user", zap.String("username", user.Username))
|
a.log.Info(
|
||||||
|
"Registered a new user",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
zap.Int64("id", user.ID))
|
||||||
|
|
||||||
|
if config.GetConfig().SmtpEnabled {
|
||||||
|
|
||||||
|
if err := a.smtp.SendEmail(
|
||||||
|
request.Email,
|
||||||
|
"Easywish",
|
||||||
|
fmt.Sprintf("Your registration code is %s", generatedCode),
|
||||||
|
); err != nil {
|
||||||
|
a.log.Error(
|
||||||
|
"Failed to send registration email",
|
||||||
|
zap.String("email", request.Email),
|
||||||
|
zap.String("username", request.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
|
||||||
|
return false, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
a.log.Debug(
|
||||||
|
"Declated 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()
|
helper.Commit()
|
||||||
|
|
||||||
a.log.Debug("Declated registration code for a new user", zap.String("username", user.Username), zap.String("code", generatedCode))
|
|
||||||
|
|
||||||
// TODO: Send verification email
|
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authServiceImpl) RegistrationComplete(request models.RegistrationBeginRequest) (*models.RegistrationCompleteResponse, error) {
|
func (a *authServiceImpl) RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error) {
|
||||||
return nil, errs.ErrNotImplemented
|
|
||||||
}
|
var user database.User
|
||||||
|
var profile database.Profile
|
||||||
|
var session database.Session
|
||||||
|
var confirmationCode database.ConfirmationCode
|
||||||
|
var accessToken, refreshToken string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
|
||||||
|
defer helper.Rollback()
|
||||||
|
|
||||||
|
user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username)
|
||||||
|
|
||||||
func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginResponse, error) {
|
|
||||||
conn, ctx, err := utils.GetDbConn()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
a.log.Warn(
|
||||||
|
"Could not find user attempting to complete registration with given username",
|
||||||
|
zap.String("username", request.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
}
|
}
|
||||||
defer conn.Close(ctx)
|
|
||||||
|
|
||||||
queries := database.New(conn)
|
a.log.Error(
|
||||||
|
"Failed to get user",
|
||||||
|
zap.String("username", request.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
user, err := queries.GetUserByLoginCredentials(ctx, database.GetUserByLoginCredentialsParams{
|
confirmationCode, err = db.TXQueries.GetValidConfirmationCodeByCode(db.CTX, database.GetValidConfirmationCodeByCodeParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CodeType: int32(enums.RegistrationCodeType),
|
||||||
|
Code: request.VerificationCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
a.log.Warn(
|
||||||
|
"User supplied unexistent confirmation code for completing registration",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
zap.String("code", request.VerificationCode),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
a.log.Error(
|
||||||
|
"Failed to acquire specified registration code",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
|
||||||
|
ID: confirmationCode.ID,
|
||||||
|
Used: utils.NewPointer(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error(
|
||||||
|
"Failed to update the user's registration code used state",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
zap.Int64("confirmation_code_id", confirmationCode.ID),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{
|
||||||
|
ID: user.ID,
|
||||||
|
Verified: utils.NewPointer(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error("Failed to update verified status for user",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
Name: request.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error("Failed to create profile for user",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
|
||||||
|
)
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.TXQueries.CreateProfileSettings(db.CTX, profile.ID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error("Failed to create profile settings for user",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: session info
|
||||||
|
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"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error(
|
||||||
|
"Failed to create a new session during registration, rolling back registration",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error(
|
||||||
|
"Failed to create tokens for newly registered user, rolling back registration",
|
||||||
|
zap.String("username", user.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.Commit()
|
||||||
|
|
||||||
|
a.log.Info(
|
||||||
|
"User verified registration",
|
||||||
|
zap.String("username", request.Username))
|
||||||
|
|
||||||
|
response := models.RegistrationCompleteResponse{Tokens: models.Tokens{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
}}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: totp
|
||||||
|
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
|
||||||
|
|
||||||
|
userRow, err = db.TXQueries.GetValidUserByLoginCredentials(db.CTX, database.GetValidUserByLoginCredentialsParams{
|
||||||
Username: request.Username,
|
Username: request.Username,
|
||||||
Password: request.Password,
|
Password: request.Password,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrUnauthorized
|
a.log.Warn(
|
||||||
|
"Failed login attempt",
|
||||||
|
zap.Error(err))
|
||||||
|
|
||||||
|
var returnedError error
|
||||||
|
|
||||||
|
switch err {
|
||||||
|
case pgx.ErrNoRows:
|
||||||
|
returnedError = errs.ErrForbidden
|
||||||
|
default:
|
||||||
|
returnedError = errs.ErrServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, refreshToken, err := utils.GenerateTokens(user.Username)
|
return nil, returnedError
|
||||||
|
}
|
||||||
|
|
||||||
return &models.LoginResponse{Tokens: models.Tokens{AccessToken: accessToken, RefreshToken: refreshToken}}, nil
|
// Until release 4, only 1 session at a time is supported
|
||||||
|
if err = db.TXQueries.TerminateAllSessionsForUserByUsername(db.CTX, request.Username); err != nil {
|
||||||
|
a.log.Error(
|
||||||
|
"Failed to terminate older sessions for user trying to log in",
|
||||||
|
zap.String("username", request.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
|
||||||
|
// TODO: use actual values for session metadata
|
||||||
|
UserID: userRow.ID,
|
||||||
|
Name: utils.NewPointer("New device"),
|
||||||
|
Platform: utils.NewPointer("Unknown"),
|
||||||
|
LatestIp: utils.NewPointer("Unknown"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error(
|
||||||
|
"Failed to create session for a new login",
|
||||||
|
zap.String("username", userRow.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, refreshToken, err := utils.GenerateTokens(userRow.Username, session.Guid.String())
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error(
|
||||||
|
"Failed to generate tokens for a new login",
|
||||||
|
zap.String("username", userRow.Username),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errs.ErrServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.Commit()
|
||||||
|
|
||||||
|
response := models.LoginResponse{Tokens: models.Tokens{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
}}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) {
|
func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
var Module = fx.Module("services",
|
var Module = fx.Module("services",
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
|
NewSmtpService,
|
||||||
NewAuthService,
|
NewAuthService,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
143
backend/internal/services/smtp.go
Normal file
143
backend/internal/services/smtp.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// 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 services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"easywish/config"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
errs "easywish/internal/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SmtpService interface {
|
||||||
|
SendEmail(to string, subject, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type smtpServiceImpl struct {
|
||||||
|
log *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSmtpService(_log *zap.Logger) SmtpService {
|
||||||
|
return &smtpServiceImpl{log: _log}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
toSlice := []string{to}
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"From": cfg.SmtpFrom,
|
||||||
|
"To": strings.Join(toSlice, ", "),
|
||||||
|
"Subject": subject,
|
||||||
|
"MIME-Version": "1.0",
|
||||||
|
"Content-Type": "text/html; charset=UTF-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for k, v := range headers {
|
||||||
|
sb.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||||
|
}
|
||||||
|
sb.WriteString("\r\n" + body)
|
||||||
|
message := []byte(sb.String())
|
||||||
|
|
||||||
|
hostPort := fmt.Sprintf("%s:%d", cfg.SmtpServer, cfg.SmtpPort)
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if cfg.SmtpUseSSL {
|
||||||
|
tlsConfig := &tls.Config{ServerName: cfg.SmtpServer}
|
||||||
|
conn, err = tls.Dial("tcp", hostPort, tlsConfig)
|
||||||
|
} else {
|
||||||
|
timeout := time.Duration(cfg.SmtpTimeout) * time.Second
|
||||||
|
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()
|
||||||
|
|
||||||
|
if !cfg.SmtpUseSSL && cfg.SmtpUseTLS {
|
||||||
|
tlsConfig := &tls.Config{ServerName: cfg.SmtpServer}
|
||||||
|
if err = client.StartTLS(tlsConfig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate if credentials exist
|
||||||
|
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))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
37
backend/internal/utils/codes.go
Normal file
37
backend/internal/utils/codes.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// 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 utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateSecure6DigitNumber() (string, error) {
|
||||||
|
maxNumber := 1000000
|
||||||
|
b := make([]byte, 4)
|
||||||
|
|
||||||
|
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
num := int(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % maxNumber
|
||||||
|
|
||||||
|
return fmt.Sprintf("%06d", num), nil
|
||||||
|
}
|
||||||
@@ -29,3 +29,9 @@ const (
|
|||||||
UserRole
|
UserRole
|
||||||
AdminRole
|
AdminRole
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type JwtTokenType int32
|
||||||
|
const (
|
||||||
|
JwtAccessTokenType JwtTokenType = iota
|
||||||
|
JwtRefreshTokenType
|
||||||
|
)
|
||||||
|
|||||||
31
backend/internal/utils/hash.go
Normal file
31
backend/internal/utils/hash.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// 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 utils
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||||
|
return string(bytes), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPasswordHash(password, hash string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -19,22 +19,27 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"easywish/config"
|
"easywish/config"
|
||||||
|
"easywish/internal/utils/enums"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GenerateTokens(username string) (accessToken, refreshToken string, err error) {
|
func GenerateTokens(username string, sessionGuid string) (accessToken, refreshToken string, err error) {
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
accessClaims := jwt.MapClaims{
|
accessClaims := jwt.MapClaims{
|
||||||
"username": username,
|
"username": username,
|
||||||
|
"guid": sessionGuid,
|
||||||
|
"type": enums.JwtAccessTokenType,
|
||||||
"exp": time.Now().Add(time.Minute * time.Duration(cfg.JwtExpAccess)).Unix(),
|
"exp": time.Now().Add(time.Minute * time.Duration(cfg.JwtExpAccess)).Unix(),
|
||||||
}
|
}
|
||||||
accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.JwtSecret))
|
accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.JwtSecret))
|
||||||
|
|
||||||
refreshClaims := jwt.MapClaims{
|
refreshClaims := jwt.MapClaims{
|
||||||
"username": username,
|
"username": username,
|
||||||
|
"guid": sessionGuid,
|
||||||
|
"type": enums.JwtRefreshTokenType,
|
||||||
"exp": time.Now().Add(time.Hour * time.Duration(cfg.JwtExpRefresh)).Unix(),
|
"exp": time.Now().Add(time.Hour * time.Duration(cfg.JwtExpRefresh)).Unix(),
|
||||||
}
|
}
|
||||||
refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.JwtSecret))
|
refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.JwtSecret))
|
||||||
|
|||||||
22
backend/internal/utils/pointer.go
Normal file
22
backend/internal/utils/pointer.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// 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 utils
|
||||||
|
|
||||||
|
func NewPointer[T any](d T) *T {
|
||||||
|
return &d
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// 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 utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"easywish/config"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GenerateSecure6DigitNumber() (string, error) {
|
|
||||||
// Generate a random number between 0 and 999999 (inclusive)
|
|
||||||
// This ensures we get a 6-digit number, including those starting with 0
|
|
||||||
max := 1000000 // Upper bound (exclusive)
|
|
||||||
b := make([]byte, 4) // A 4-byte slice is sufficient for a 32-bit integer
|
|
||||||
|
|
||||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert bytes to an integer
|
|
||||||
// We use a simple modulo operation to get a number within our desired range.
|
|
||||||
// While this introduces a slight bias for very large ranges, for 1,000,000
|
|
||||||
// it's negligible and simpler than more complex methods like rejection sampling.
|
|
||||||
num := int(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % max
|
|
||||||
|
|
||||||
return fmt.Sprintf("%06d", num), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidatePassword(password string) error {
|
|
||||||
cfg := config.GetConfig()
|
|
||||||
|
|
||||||
if cfg.PasswordCheckLength {
|
|
||||||
passwordLength := len(password); if passwordLength < 8 || passwordLength > 100 {
|
|
||||||
return errors.New("Password must be between 8 and 100 characters")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.PasswordCheckNumbers {
|
|
||||||
numbersPresent := regexp.MustCompile(`[0-9]`).MatchString(password); if !numbersPresent {
|
|
||||||
return errors.New("Password must contain at least 1 number")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.PasswordCheckCases {
|
|
||||||
differentCasesPresent := regexp.MustCompile(`(?=.*[a-z])(?=.*[A-Z])`).MatchString(password); if !differentCasesPresent {
|
|
||||||
return errors.New("Password must have uppercase and lowercase characters")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.PasswordCheckSymbols {
|
|
||||||
symbolsPresent := regexp.MustCompile(`[.,/;'[\]\-=_+{}:"<>?\/|!@#$%^&*()~]`).MatchString(password); if !symbolsPresent {
|
|
||||||
return errors.New("Password must contain at least one special symbol")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.PasswordCheckLeaked {
|
|
||||||
// TODO: implement checking leaked passwords via rockme.txt
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
83
backend/internal/validation/custom.go
Normal file
83
backend/internal/validation/custom.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// 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 validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"easywish/config"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomValidatorHandler struct {
|
||||||
|
Function func(fl validator.FieldLevel) bool
|
||||||
|
FieldName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCustomHandlers() []CustomValidatorHandler {
|
||||||
|
|
||||||
|
handlers := []CustomValidatorHandler{
|
||||||
|
|
||||||
|
{
|
||||||
|
FieldName: "username",
|
||||||
|
Function: func(fl validator.FieldLevel) bool {
|
||||||
|
username := fl.Field().String()
|
||||||
|
return regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`).MatchString(username)
|
||||||
|
}},
|
||||||
|
|
||||||
|
{
|
||||||
|
FieldName: "name",
|
||||||
|
Function: func(fl validator.FieldLevel) bool {
|
||||||
|
username := fl.Field().String()
|
||||||
|
return regexp.MustCompile(`^.{1,75}$`).MatchString(username)
|
||||||
|
}},
|
||||||
|
|
||||||
|
{
|
||||||
|
FieldName: "password",
|
||||||
|
Function: func(fl validator.FieldLevel) bool {
|
||||||
|
password := fl.Field().String()
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
if cfg.PasswordMaxLength < len(password) || len(password) < cfg.PasswordMinLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PasswordCheckNumbers && !regexp.MustCompile(`\d+`).MatchString(password) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PasswordCheckCases && !regexp.MustCompile(`^(?=.*[a-z])(?=.*[A-Z]).*$`).MatchString(password) ||
|
||||||
|
cfg.PasswordCheckCharacters && !regexp.MustCompile(`[a-zA-Z]`).MatchString(password) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PasswordCheckSymbols && !regexp.MustCompile(`[.,/;'[\]\-=_+{}:"<>?\/|!@#$%^&*()~]`).MatchString(password) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PasswordCheckLeaked {
|
||||||
|
// TODO: implement rockme check
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}},
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return handlers
|
||||||
|
}
|
||||||
28
backend/internal/validation/setup.go
Normal file
28
backend/internal/validation/setup.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// 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 validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Module = fx.Module("validation",
|
||||||
|
fx.Provide(
|
||||||
|
NewValidator,
|
||||||
|
),
|
||||||
|
)
|
||||||
32
backend/internal/validation/validator.go
Normal file
32
backend/internal/validation/validator.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// 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 validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewValidator() *validator.Validate {
|
||||||
|
v := validator.New()
|
||||||
|
|
||||||
|
for _, handler := range GetCustomHandlers() {
|
||||||
|
v.RegisterValidation(handler.FieldName, handler.Function)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -16,9 +16,34 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
environment:
|
environment:
|
||||||
|
ENVIRONMENT: ${ENVIRONMENT}
|
||||||
|
HOSTNAME: ${HOSTNAME}
|
||||||
|
PORT: ${PORT}
|
||||||
POSTGRES_URL: ${POSTGRES_URL}
|
POSTGRES_URL: ${POSTGRES_URL}
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
MINIO_URL: ${MINIO_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:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
101
sqlc/query.sql
101
sqlc/query.sql
@@ -1,5 +1,22 @@
|
|||||||
-- vim:fileencoding=utf-8:foldmethod=marker
|
-- vim:fileencoding=utf-8:foldmethod=marker
|
||||||
|
|
||||||
|
-- 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/>.
|
||||||
|
|
||||||
--: User Object {{{
|
--: User Object {{{
|
||||||
|
|
||||||
;-- name: CreateUser :one
|
;-- name: CreateUser :one
|
||||||
@@ -8,7 +25,9 @@ VALUES ($1, false) RETURNING *;
|
|||||||
|
|
||||||
;-- name: UpdateUser :exec
|
;-- name: UpdateUser :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET verified = $2, deleted = $3
|
SET
|
||||||
|
verified = COALESCE($2, verified),
|
||||||
|
deleted = COALESCE($3, deleted)
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
;-- name: UpdateUserByUsername :exec
|
;-- name: UpdateUserByUsername :exec
|
||||||
@@ -32,7 +51,12 @@ WHERE id = $1;
|
|||||||
SELECT * FROM users
|
SELECT * FROM users
|
||||||
WHERE username = $1;
|
WHERE username = $1;
|
||||||
|
|
||||||
;-- name: GetUserByLoginCredentials :one
|
;-- name: GetUserByEmail :one
|
||||||
|
SELECT users.* FROM users
|
||||||
|
JOIN login_informations linfo ON linfo.user_id = users.id
|
||||||
|
WHERE linfo.email = @email::text;
|
||||||
|
|
||||||
|
;-- name: GetValidUserByLoginCredentials :one
|
||||||
SELECT
|
SELECT
|
||||||
users.id,
|
users.id,
|
||||||
users.username,
|
users.username,
|
||||||
@@ -58,7 +82,12 @@ VALUES ( $1, $2, $3, $4) RETURNING *;
|
|||||||
|
|
||||||
;-- name: UpdateBannedUser :exec
|
;-- name: UpdateBannedUser :exec
|
||||||
UPDATE banned_users
|
UPDATE banned_users
|
||||||
SET reason = $2, expires_at = $3, banned_by = $4, pardoned = $5, pardoned_by = $6
|
SET
|
||||||
|
reason = COALESCE($2, reason),
|
||||||
|
expires_at = COALESCE($3, expires_at),
|
||||||
|
banned_by = COALESCE($4, banned_by),
|
||||||
|
pardoned = COALESCE($5, pardoned),
|
||||||
|
pardoned_by = COALESCE($6, pardoned_by)
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
;-- name: GetUserBans :many
|
;-- name: GetUserBans :many
|
||||||
@@ -76,11 +105,22 @@ WHERE users.username = $1;
|
|||||||
|
|
||||||
;-- name: CreateLoginInformation :one
|
;-- name: CreateLoginInformation :one
|
||||||
INSERT INTO login_informations(user_id, email, password_hash)
|
INSERT INTO login_informations(user_id, email, password_hash)
|
||||||
VALUES ( $1, $2, crypt(@password::text, gen_salt('bf')) ) RETURNING *;
|
VALUES ( $1, $2, @password_hash::text ) RETURNING *;
|
||||||
|
|
||||||
;-- name: UpdateLoginInformationByUsername :exec
|
;-- name: UpdateLoginInformationByUsername :exec
|
||||||
UPDATE login_informations
|
UPDATE login_informations
|
||||||
SET email = $2, password_hash = crypt(@password::text, gen_salt('bf')), totp_encrypted = $4, email_2fa_enabled = $5, password_change_date = $6
|
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
|
||||||
|
),
|
||||||
|
totp_encrypted = COALESCE($4, totp_encrypted),
|
||||||
|
email_2fa_enabled = COALESCE($5, email_2fa_enabled),
|
||||||
|
password_change_date = COALESCE($6, password_change_date)
|
||||||
FROM users
|
FROM users
|
||||||
WHERE users.username = $1 AND login_informations.user_id = users.id;
|
WHERE users.username = $1 AND login_informations.user_id = users.id;
|
||||||
|
|
||||||
@@ -95,15 +135,22 @@ WHERE users.username = $1;
|
|||||||
|
|
||||||
;-- name: CreateConfirmationCode :one
|
;-- name: CreateConfirmationCode :one
|
||||||
INSERT INTO confirmation_codes(user_id, code_type, code_hash)
|
INSERT INTO confirmation_codes(user_id, code_type, code_hash)
|
||||||
VALUES ($1, $2, crypt(@code::text, gen_salt('bf'))) RETURNING *;
|
VALUES ($1, $2, @code_hash) RETURNING *;
|
||||||
|
|
||||||
;-- name: GetConfirmationCodeByCode :one
|
;-- name: GetValidConfirmationCodeByCode :one
|
||||||
SELECT * FROM confirmation_codes
|
SELECT * FROM confirmation_codes
|
||||||
WHERE user_id = $1 AND code_type = $2 AND expires_at > CURRENT_TIMESTAMP AND code_hash = crypt($3, code_hash);
|
WHERE
|
||||||
|
user_id = $1 AND
|
||||||
|
code_type = $2 AND
|
||||||
|
expires_at > CURRENT_TIMESTAMP AND
|
||||||
|
used IS FALSE AND
|
||||||
|
code_hash = crypt(@code::text, code_hash);
|
||||||
|
|
||||||
;-- name: UpdateConfirmationCode :exec
|
;-- name: UpdateConfirmationCode :exec
|
||||||
UPDATE confirmation_codes
|
UPDATE confirmation_codes
|
||||||
SET used = $2, deleted = $3
|
SET
|
||||||
|
used = COALESCE($2, used),
|
||||||
|
deleted = COALESCE($3, deleted)
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
;-- name: PruneExpiredConfirmationCodes :exec
|
;-- name: PruneExpiredConfirmationCodes :exec
|
||||||
@@ -120,13 +167,25 @@ VALUES ($1, $2, $3, $4) RETURNING *;
|
|||||||
|
|
||||||
;-- name: UpdateSession :exec
|
;-- name: UpdateSession :exec
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET name = $2, platform = $3, latest_ip = $4, login_time = $5, last_seen_date = $6, terminated = $7
|
SET
|
||||||
|
name = COALESCE($2, name),
|
||||||
|
platform = COALESCE($3, platform),
|
||||||
|
latest_ip = COALESCE($4, latest_ip),
|
||||||
|
login_time = COALESCE($5, login_time),
|
||||||
|
last_seen_date = COALESCE($6, last_seen_date),
|
||||||
|
terminated = COALESCE($7, terminated)
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
;-- name: GetUserSessions :many
|
;-- name: GetUserSessions :many
|
||||||
SELECT * FROM sessions
|
SELECT * FROM sessions
|
||||||
WHERE user_id = $1 AND terminated IS FALSE;
|
WHERE user_id = $1 AND terminated IS FALSE;
|
||||||
|
|
||||||
|
;-- name: TerminateAllSessionsForUserByUsername :exec
|
||||||
|
UPDATE sessions
|
||||||
|
SET terminated = TRUE
|
||||||
|
FROM users
|
||||||
|
WHERE sessions.user_id = users.id AND users.username = @username::text;
|
||||||
|
|
||||||
;-- name: PruneTerminatedSessions :exec
|
;-- name: PruneTerminatedSessions :exec
|
||||||
DELETE FROM sessions
|
DELETE FROM sessions
|
||||||
WHERE terminated IS TRUE;
|
WHERE terminated IS TRUE;
|
||||||
@@ -141,7 +200,13 @@ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *;
|
|||||||
|
|
||||||
;-- name: UpdateProfileByUsername :exec
|
;-- name: UpdateProfileByUsername :exec
|
||||||
UPDATE profiles
|
UPDATE profiles
|
||||||
SET name = $2, bio = $3, birthday = $4, avatar_url = $5, color = $6, color_grad = $7
|
SET
|
||||||
|
name = COALESCE($2, name),
|
||||||
|
bio = COALESCE($3, bio),
|
||||||
|
birthday = COALESCE($4, birthday),
|
||||||
|
avatar_url = COALESCE($5, avatar_url),
|
||||||
|
color = COALESCE($6, color),
|
||||||
|
color_grad = COALESCE($7, color_grad)
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = $1;
|
WHERE username = $1;
|
||||||
|
|
||||||
@@ -203,13 +268,13 @@ VALUES ($1) RETURNING *;
|
|||||||
;-- name: UpdateProfileSettings :exec
|
;-- name: UpdateProfileSettings :exec
|
||||||
UPDATE profile_settings
|
UPDATE profile_settings
|
||||||
SET
|
SET
|
||||||
hide_fulfilled = $2,
|
hide_fulfilled = COALESCE($2, hide_fulfilled),
|
||||||
hide_profile_details = $3,
|
hide_profile_details = COALESCE($3, hide_profile_details),
|
||||||
hide_for_unauthenticated = $4,
|
hide_for_unauthenticated = COALESCE($4, hide_for_unauthenticated),
|
||||||
hide_birthday = $5,
|
hide_birthday = COALESCE($5, hide_birthday),
|
||||||
hide_dates = $6,
|
hide_dates = COALESCE($6, hide_dates),
|
||||||
captcha = $7,
|
captcha = COALESCE($7, captcha),
|
||||||
followers_only_interaction = $8
|
followers_only_interaction = COALESCE($8, followers_only_interaction)
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
;-- name: GetProfileSettingsByUsername :one
|
;-- name: GetProfileSettingsByUsername :one
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
|
-- 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/>.
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "users" (
|
CREATE TABLE IF NOT EXISTS "users" (
|
||||||
@@ -41,7 +60,7 @@ CREATE TABLE IF NOT EXISTS "confirmation_codes" (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
guid UUID NOT NULL DEFAULT gen_random_uuid(),
|
guid UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
name VARCHAR(100),
|
name VARCHAR(100),
|
||||||
platform VARCHAR(32),
|
platform VARCHAR(32),
|
||||||
|
|||||||
Reference in New Issue
Block a user