Compare commits

...

13 Commits

Author SHA1 Message Date
849403a137 feat: confirm OTP integration 2025-06-07 01:34:48 +02:00
8d15c9b8b2 feat: request OTP query 2025-06-07 01:34:38 +02:00
87af1834cf feat: email verify state 2025-06-07 01:34:28 +02:00
357583f54d feat: navigate to current stage of verification 2025-06-07 01:34:13 +02:00
aa6de76ded feat: redirect to verify pages 2025-06-07 01:33:50 +02:00
ab3c2d1eb0 feat: stepper improvements 2025-06-07 01:33:40 +02:00
644cf2a358 feat: email verify APIs 2025-06-07 01:33:21 +02:00
5b1ed9925d feat: verify routes 2025-06-07 01:33:14 +02:00
4071a50a37 feat: split auth routes in files 2025-06-07 01:32:56 +02:00
dd7c51efd8 feat: update user verifications 2025-06-07 01:32:37 +02:00
8902f4d187 feat: redis configuration & client 2025-06-07 01:32:22 +02:00
6666b20464 feat: go-redis pkg 2025-06-07 01:31:55 +02:00
c5f288ba1e feat: redis configuration 2025-06-07 01:31:47 +02:00
23 changed files with 600 additions and 222 deletions

View File

@ -5,6 +5,8 @@ GUARD_URI="http://localhost:3001"
GUARD_DB_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable" GUARD_DB_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
GUARD_REDIS_URL="redis://guard:guard@localhost:6379/0"
GUARD_ADMIN_NAME="admin" GUARD_ADMIN_NAME="admin"
GUARD_ADMIN_EMAIL="admin@test.net" GUARD_ADMIN_EMAIL="admin@test.net"
GUARD_ADMIN_PASSWORD="secret" GUARD_ADMIN_PASSWORD="secret"

View File

@ -8,6 +8,7 @@ import (
"gitea.local/admin/hspguard/internal/admin" "gitea.local/admin/hspguard/internal/admin"
"gitea.local/admin/hspguard/internal/auth" "gitea.local/admin/hspguard/internal/auth"
"gitea.local/admin/hspguard/internal/cache"
"gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/oauth" "gitea.local/admin/hspguard/internal/oauth"
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
@ -21,14 +22,16 @@ type APIServer struct {
addr string addr string
repo *repository.Queries repo *repository.Queries
storage *storage.FileStorage storage *storage.FileStorage
cache *cache.Client
cfg *config.AppConfig cfg *config.AppConfig
} }
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cfg *config.AppConfig) *APIServer { func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cache *cache.Client, cfg *config.AppConfig) *APIServer {
return &APIServer{ return &APIServer{
addr: addr, addr: addr,
repo: db, repo: db,
storage: minio, storage: minio,
cache: cache,
cfg: cfg, cfg: cfg,
} }
} }
@ -47,7 +50,7 @@ func (s *APIServer) Run() error {
userHandler := user.NewUserHandler(s.repo, s.storage, s.cfg) userHandler := user.NewUserHandler(s.repo, s.storage, s.cfg)
userHandler.RegisterRoutes(r) userHandler.RegisterRoutes(r)
authHandler := auth.NewAuthHandler(s.repo, s.cfg) authHandler := auth.NewAuthHandler(s.repo, s.cache, s.cfg)
authHandler.RegisterRoutes(r) authHandler.RegisterRoutes(r)
oauthHandler.RegisterRoutes(r) oauthHandler.RegisterRoutes(r)

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"gitea.local/admin/hspguard/cmd/hspguard/api" "gitea.local/admin/hspguard/cmd/hspguard/api"
"gitea.local/admin/hspguard/internal/cache"
"gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage" "gitea.local/admin/hspguard/internal/storage"
@ -41,9 +42,11 @@ func main() {
fStorage := storage.New(&cfg) fStorage := storage.New(&cfg)
cache := cache.NewClient(&cfg)
user.EnsureAdminUser(ctx, &cfg, repo) user.EnsureAdminUser(ctx, &cfg, repo)
server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, &cfg) server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, cache, &cfg)
if err := server.Run(); err != nil { if err := server.Run(); err != nil {
log.Fatalln("ERR: Failed to start server:", err) log.Fatalln("ERR: Failed to start server:", err)
} }

View File

@ -8,3 +8,18 @@ services:
POSTGRES_PASSWORD: guard POSTGRES_PASSWORD: guard
ports: ports:
- "5432:5432" - "5432:5432"
cache:
image: redis:7.2 # or newer
container_name: guard-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
- ./redis.conf:/usr/local/etc/redis/redis.conf
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
restart: unless-stopped
volumes:
redis-data:
driver: local

3
go.mod
View File

@ -11,6 +11,8 @@ require (
) )
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
@ -23,6 +25,7 @@ require (
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.92 // indirect github.com/minio/minio-go/v7 v7.0.92 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/redis/go-redis/v9 v9.10.0 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/crypto v0.38.0 // indirect

6
go.sum
View File

@ -1,6 +1,10 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
@ -38,6 +42,8 @@ github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1Gsh
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

81
internal/auth/login.go Normal file
View File

@ -0,0 +1,81 @@
package auth
import (
"encoding/json"
"log"
"net/http"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
)
type LoginParams struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
var params LoginParams
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&params); err != nil {
web.Error(w, "failed to parse request body", http.StatusBadRequest)
return
}
if params.Email == "" || params.Password == "" {
web.Error(w, "missing required fields", http.StatusBadRequest)
return
}
log.Printf("DEBUG: looking for user with following params: %#v\n", params)
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
return
}
if !util.VerifyPassword(params.Password, user.PasswordHash) {
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
return
}
access, refresh, err := h.signTokens(&user)
if err != nil {
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
return
}
if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil {
web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
return
}
encoder := json.NewEncoder(w)
type Response struct {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
// fields required for UI in account selector, e.g. email, full name and avatar
FullName string `json:"full_name"`
Email string `json:"email"`
Id string `json:"id"`
ProfilePicture *string `json:"profile_picture"`
// Avatar
}
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
FullName: user.FullName,
Email: user.Email,
Id: user.ID.String(),
ProfilePicture: user.ProfilePicture,
// Avatar
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

31
internal/auth/profile.go Normal file
View File

@ -0,0 +1,31 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(types.NewUserDTO(&user)); err != nil {
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
}
}

79
internal/auth/refresh.go Normal file
View File

@ -0,0 +1,79 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
web.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
parts := strings.Split(authHeader, "Bearer ")
if len(parts) != 2 {
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
return
}
tokenStr := parts[1]
token, userClaims, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey)
if err != nil || !token.Valid {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
}
expire, err := userClaims.GetExpirationTime()
if err != nil {
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
return
}
if time.Now().After(expire.Time) {
web.Error(w, "token is expired", http.StatusUnauthorized)
return
}
userId, err := uuid.Parse(userClaims.Subject)
if err != nil {
web.Error(w, "failed to parsej user id from token", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), userId)
if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
return
}
access, refresh, err := h.signTokens(&user)
if err != nil {
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
return
}
type Response struct {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

View File

@ -1,26 +1,21 @@
package auth package auth
import ( import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time" "time"
"gitea.local/admin/hspguard/internal/cache"
"gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware" imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
) )
type AuthHandler struct { type AuthHandler struct {
repo *repository.Queries repo *repository.Queries
cache *cache.Client
cfg *config.AppConfig cfg *config.AppConfig
} }
@ -60,9 +55,10 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
return accessToken, refreshToken, nil return accessToken, refreshToken, nil
} }
func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandler { func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {
return &AuthHandler{ return &AuthHandler{
repo, repo,
cache,
cfg, cfg,
} }
} }
@ -74,166 +70,11 @@ func (h *AuthHandler) RegisterRoutes(api chi.Router) {
protected.Use(authMiddleware.Runner) protected.Use(authMiddleware.Runner)
protected.Get("/profile", h.getProfile) protected.Get("/profile", h.getProfile)
protected.Post("/email", h.requestEmailOtp)
protected.Post("/email/otp", h.confirmOtp)
}) })
r.Post("/login", h.login) r.Post("/login", h.login)
r.Post("/refresh", h.refreshToken) r.Post("/refresh", h.refreshToken)
}) })
} }
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
web.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
parts := strings.Split(authHeader, "Bearer ")
if len(parts) != 2 {
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
return
}
tokenStr := parts[1]
token, userClaims, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey)
if err != nil || !token.Valid {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
}
expire, err := userClaims.GetExpirationTime()
if err != nil {
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
return
}
if time.Now().After(expire.Time) {
web.Error(w, "token is expired", http.StatusUnauthorized)
return
}
userId, err := uuid.Parse(userClaims.Subject)
if err != nil {
web.Error(w, "failed to parsej user id from token", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), userId)
if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
return
}
access, refresh, err := h.signTokens(&user)
if err != nil {
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
return
}
type Response struct {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
}
encoder := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(types.NewUserDTO(&user)); err != nil {
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
}
}
type LoginParams struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
var params LoginParams
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&params); err != nil {
web.Error(w, "failed to parse request body", http.StatusBadRequest)
return
}
if params.Email == "" || params.Password == "" {
web.Error(w, "missing required fields", http.StatusBadRequest)
return
}
log.Printf("DEBUG: looking for user with following params: %#v\n", params)
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
return
}
if !util.VerifyPassword(params.Password, user.PasswordHash) {
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
return
}
access, refresh, err := h.signTokens(&user)
if err != nil {
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
return
}
if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil {
web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
return
}
encoder := json.NewEncoder(w)
type Response struct {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
// fields required for UI in account selector, e.g. email, full name and avatar
FullName string `json:"full_name"`
Email string `json:"email"`
Id string `json:"id"`
ProfilePicture *string `json:"profile_picture"`
// Avatar
}
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
FullName: user.FullName,
Email: user.Email,
Id: user.ID.String(),
ProfilePicture: user.ProfilePicture,
// Avatar
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

99
internal/auth/verify.go Normal file
View File

@ -0,0 +1,99 @@
package auth
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"time"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
func (h *AuthHandler) requestEmailOtp(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
if user.EmailVerified {
web.Error(w, "email is already verified", http.StatusBadRequest)
return
}
number := rand.Intn(1000000) // 0 to 999999
padded := fmt.Sprintf("%06d", number) // Always 6 characters
if _, err := h.cache.Set(r.Context(), fmt.Sprintf("otp-%s", user.ID.String()), padded, 5*time.Minute).Result(); err != nil {
log.Println("ERR: Failed to save OTP in cache:", err)
web.Error(w, "failed to generate otp", http.StatusInternalServerError)
return
}
log.Printf("INFO: Saved OTP %s\n", padded)
w.WriteHeader(http.StatusCreated)
}
type ConfirmOtpRequest struct {
OTP string `json:"otp"`
}
func (h *AuthHandler) confirmOtp(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
if user.EmailVerified {
web.Error(w, "email is already verified", http.StatusBadRequest)
return
}
var req ConfirmOtpRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
web.Error(w, "invalid request", http.StatusBadRequest)
return
}
val, err := h.cache.Get(r.Context(), fmt.Sprintf("otp-%s", user.ID.String())).Result()
if err != nil {
web.Error(w, "otp verification session not found", http.StatusNotFound)
return
}
log.Printf("INFO: Comparing OTP %s == %s\n", req.OTP, val)
if req.OTP == val {
err := h.repo.UserVerifyEmail(r.Context(), user.ID)
if err != nil {
log.Println("ERR: Failed to update email_verified:", err)
web.Error(w, "failed to verify email", http.StatusInternalServerError)
return
}
} else {
web.Error(w, "otp verification failed", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}

36
internal/cache/mod.go vendored Normal file
View File

@ -0,0 +1,36 @@
package cache
import (
"log"
"time"
"gitea.local/admin/hspguard/internal/config"
"github.com/redis/go-redis/v9"
"golang.org/x/net/context"
)
type Client struct {
rClient *redis.Client
}
func NewClient(cfg *config.AppConfig) *Client {
opts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
log.Fatalln("ERR: Failed to get redis options:", err)
return nil
}
client := redis.NewClient(opts)
return &Client{
rClient: client,
}
}
func (c *Client) Set(ctx context.Context, key string, value any, expiration time.Duration) *redis.StatusCmd {
return c.rClient.Set(ctx, key, value, expiration)
}
func (c *Client) Get(ctx context.Context, key string) *redis.StringCmd {
return c.rClient.Get(ctx, key)
}

View File

@ -14,6 +14,7 @@ type AppConfig struct {
Host string `env:"GUARD_HOST" default:"127.0.0.1"` Host string `env:"GUARD_HOST" default:"127.0.0.1"`
Uri string `env:"GUARD_URI" default:"http://127.0.0.1:3001"` Uri string `env:"GUARD_URI" default:"http://127.0.0.1:3001"`
DatabaseURL string `env:"GUARD_DB_URL" required:"true"` DatabaseURL string `env:"GUARD_DB_URL" required:"true"`
RedisURL string `env:"GUARD_REDIS_URL" default:"redis://localhost:6379/0"`
Admin AdminConfig Admin AdminConfig
Jwt JwtConfig Jwt JwtConfig
Minio MinioConfig Minio MinioConfig

View File

@ -197,3 +197,36 @@ func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePic
_, err := q.db.Exec(ctx, updateProfilePicture, arg.ProfilePicture, arg.ID) _, err := q.db.Exec(ctx, updateProfilePicture, arg.ProfilePicture, arg.ID)
return err return err
} }
const userVerifyAvatar = `-- name: UserVerifyAvatar :exec
UPDATE users
SET avatar_verified = true
WHERE id = $1
`
func (q *Queries) UserVerifyAvatar(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, userVerifyAvatar, id)
return err
}
const userVerifyComplete = `-- name: UserVerifyComplete :exec
UPDATE users
SET verified = true
WHERE id = $1
`
func (q *Queries) UserVerifyComplete(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, userVerifyComplete, id)
return err
}
const userVerifyEmail = `-- name: UserVerifyEmail :exec
UPDATE users
SET email_verified = true
WHERE id = $1
`
func (q *Queries) UserVerifyEmail(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, userVerifyEmail, id)
return err
}

View File

@ -23,6 +23,21 @@ UPDATE users
SET profile_picture = $1 SET profile_picture = $1
WHERE id = $2; WHERE id = $2;
-- name: UserVerifyEmail :exec
UPDATE users
SET email_verified = true
WHERE id = $1;
-- name: UserVerifyAvatar :exec
UPDATE users
SET avatar_verified = true
WHERE id = $1;
-- name: UserVerifyComplete :exec
UPDATE users
SET verified = true
WHERE id = $1;
-- name: UpdateLastLogin :exec -- name: UpdateLastLogin :exec
UPDATE users UPDATE users
SET last_login = NOW() SET last_login = NOW()

4
redis.conf Normal file
View File

@ -0,0 +1,4 @@
# Enable ACL
user default off
user guard on >guard allcommands allkeys

25
web/src/api/verify.ts Normal file
View File

@ -0,0 +1,25 @@
import { axios, handleApiError } from ".";
export const requestEmailOtpApi = async (): Promise<void> => {
const response = await axios.post("/api/v1/auth/email");
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export interface ConfirmEmailRequest {
otp: string;
}
export const confirmEmailApi = async (
req: ConfirmEmailRequest,
): Promise<void> => {
const response = await axios.post("/api/v1/auth/email/otp", req);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

View File

@ -27,7 +27,6 @@ export const Stepper: React.FC<StepperProps> = ({ steps, currentStep }) => {
return ( return (
<div className="flex flex-col sm:flex-row sm:items-center w-full max-w-2xl mx-auto mb-5 sm:mb-8 gap-5 relative"> <div className="flex flex-col sm:flex-row sm:items-center w-full max-w-2xl mx-auto mb-5 sm:mb-8 gap-5 relative">
{steps.map((step, idx) => ( {steps.map((step, idx) => (
<>
<div <div
key={idx} key={idx}
className={`sm:flex p-4 pb-0 sm:p-0 flex-1 items-center ${idx < stepIndex ? "opacity-70" : ""} ${idx === stepIndex ? "flex" : "hidden"}`} className={`sm:flex p-4 pb-0 sm:p-0 flex-1 items-center ${idx < stepIndex ? "opacity-70" : ""} ${idx === stepIndex ? "flex" : "hidden"}`}
@ -80,7 +79,6 @@ export const Stepper: React.FC<StepperProps> = ({ steps, currentStep }) => {
<div className="flex-1 h-1 mx-2 min-w sm:mx-4 rounded bg-gray-300 dark:bg-gray-600" /> <div className="flex-1 h-1 mx-2 min-w sm:mx-4 rounded bg-gray-300 dark:bg-gray-600" />
)} */} )} */}
</div> </div>
</>
))} ))}
<div className="sm:hidden relative h-1 w-full bg-gray-200 dark:bg-gray-700 overflow-hidden"> <div className="sm:hidden relative h-1 w-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div <div

View File

@ -63,6 +63,18 @@ const AuthLayout = () => {
connecting, connecting,
]); ]);
const verificationRequired = useMemo(() => {
return (
authProfile?.email_verified === false ||
authProfile?.avatar_verified === false ||
authProfile?.verified === false
);
}, [
authProfile?.avatar_verified,
authProfile?.email_verified,
authProfile?.verified,
]);
// OAuth // OAuth
useEffect(() => { useEffect(() => {
console.log( console.log(
@ -140,7 +152,7 @@ const AuthLayout = () => {
if ( if (
!signInRequired && !signInRequired &&
authProfile?.email_verified === false && verificationRequired &&
!location.pathname.startsWith("/verify") !location.pathname.startsWith("/verify")
) { ) {
return <Navigate to="/verify" />; return <Navigate to="/verify" />;

View File

@ -3,7 +3,7 @@ import { useAuth } from "@/store/auth";
import { useVerify } from "@/store/verify"; import { useVerify } from "@/store/verify";
import { Eye, MailCheck, ScanFace } from "lucide-react"; import { Eye, MailCheck, ScanFace } from "lucide-react";
import { useEffect, type FC } from "react"; import { useEffect, type FC } from "react";
import { Outlet, useLocation } from "react-router"; import { Navigate, Outlet, useLocation } from "react-router";
const steps = [ const steps = [
{ {
@ -37,6 +37,18 @@ const VerificationLayout: FC = () => {
if (profile) loadStep(profile); if (profile) loadStep(profile);
}, [loadStep, profile]); }, [loadStep, profile]);
if (step === "email" && !location.pathname.startsWith("/verify/email")) {
return <Navigate to="/verify/email" />;
}
if (step === "avatar" && !location.pathname.startsWith("/verify/avatar")) {
return <Navigate to="/verify/avatar" />;
}
if (step === "review" && !location.pathname.startsWith("/verify/review")) {
return <Navigate to="/verify/review" />;
}
return ( return (
<div className="w-full h-screen max-h-screen overflow-y-auto flex flex-col items-center sm:justify-center bg-white/50 dark:bg-black/50"> <div className="w-full h-screen max-h-screen overflow-y-auto flex flex-col items-center sm:justify-center bg-white/50 dark:bg-black/50">
<div className="w-full h-full sm:w-auto sm:h-auto"> <div className="w-full h-full sm:w-auto sm:h-auto">

View File

@ -1,9 +1,21 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { type FC } from "react"; import { useVerify } from "@/store/verify";
import { Link } from "react-router"; import { useCallback, useState, type FC } from "react";
const VerifyEmailOtpPage: FC = () => { const VerifyEmailOtpPage: FC = () => {
const [otp, setOtp] = useState("");
const confirmOtp = useVerify((s) => s.confirmOTP);
const confirming = useVerify((s) => s.confirming);
const handleVerify = useCallback(() => {
if (otp.length !== 6) return;
confirmOtp({
otp,
});
}, [confirmOtp, otp]);
return ( return (
<div className="flex flex-col items-stretch gap-2 max-w-sm mx-auto p-4"> <div className="flex flex-col items-stretch gap-2 max-w-sm mx-auto p-4">
<h1 className="text-xl font-medium dark:text-gray-200"> <h1 className="text-xl font-medium dark:text-gray-200">
@ -13,10 +25,22 @@ const VerifyEmailOtpPage: FC = () => {
We've sent you verification code on your email address, please open your We've sent you verification code on your email address, please open your
mailbox and enter the verification code in order to continue. mailbox and enter the verification code in order to continue.
</p> </p>
<Input placeholder="Enter OTP" /> <Input
<Link to="/verify/avatar" className="w-full"> placeholder="Enter OTP"
<Button className="mt-3 w-full">Verify</Button> value={otp}
</Link> onChange={(e) => {
e.preventDefault();
setOtp(e.target.value);
}}
/>
<Button
className="mt-3 w-full"
onClick={handleVerify}
loading={confirming}
disabled={confirming}
>
Verify
</Button>
</div> </div>
); );
}; };

View File

@ -1,12 +1,18 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { useVerify } from "@/store/verify";
import maskEmail from "@/util/maskEmail"; import maskEmail from "@/util/maskEmail";
import { useCallback, useMemo, useState, type FC } from "react"; import { useCallback, useEffect, useMemo, useState, type FC } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
const VerifyEmailPage: FC = () => { const VerifyEmailPage: FC = () => {
const profile = useAuth((s) => s.profile); const profile = useAuth((s) => s.profile);
const requestOtp = useVerify((s) => s.requestOTP);
const requesting = useVerify((s) => s.requesting);
const requested = useVerify((s) => s.requested);
const navigate = useNavigate(); const navigate = useNavigate();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -17,9 +23,15 @@ const VerifyEmailPage: FC = () => {
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
if (matches) { if (matches) {
requestOtp();
}
}, [matches, requestOtp]);
useEffect(() => {
if (requested) {
navigate("/verify/email/otp"); navigate("/verify/email/otp");
} }
}, [matches, navigate]); }, [navigate, requested]);
return ( return (
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4"> <div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
@ -38,7 +50,8 @@ const VerifyEmailPage: FC = () => {
<Button <Button
className={`mt-3 ${!matches ? "opacity-60" : ""}`} className={`mt-3 ${!matches ? "opacity-60" : ""}`}
onClick={handleNext} onClick={handleNext}
disabled={!matches} disabled={!matches || requesting}
loading={requesting}
> >
Next Next
</Button> </Button>

View File

@ -1,6 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { useAuth } from "./auth";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
import {
confirmEmailApi,
requestEmailOtpApi,
type ConfirmEmailRequest,
} from "@/api/verify";
import { useAuth } from "./auth";
export type VerifyStep = "email" | "avatar" | "review"; export type VerifyStep = "email" | "avatar" | "review";
@ -8,11 +13,22 @@ export interface IVerifyState {
step: VerifyStep | null; step: VerifyStep | null;
loadStep: (profile: UserProfile) => void; loadStep: (profile: UserProfile) => void;
requesting: boolean;
requested: boolean;
requestOTP: () => Promise<void>;
confirming: boolean;
confirmOTP: (req: ConfirmEmailRequest) => Promise<void>;
} }
export const useVerify = create<IVerifyState>((set) => ({ export const useVerify = create<IVerifyState>((set) => ({
step: null, step: null,
requesting: false,
requested: false,
confirming: false,
loadStep: (profile) => { loadStep: (profile) => {
if (!profile.email_verified) { if (!profile.email_verified) {
set({ step: "email" }); set({ step: "email" });
@ -31,4 +47,30 @@ export const useVerify = create<IVerifyState>((set) => ({
set({ step: null }); set({ step: null });
}, },
requestOTP: async () => {
set({ requesting: true, requested: false });
try {
await requestEmailOtpApi();
set({ requested: true });
} catch (err) {
console.log("ERR: Failed to request OTP:", err);
} finally {
set({ requesting: false });
}
},
confirmOTP: async (req: ConfirmEmailRequest) => {
set({ confirming: true });
try {
await confirmEmailApi(req);
await useAuth.getState().authenticate();
} catch (err) {
console.log("ERR: Failed to request OTP:", err);
} finally {
set({ confirming: false });
}
},
})); }));