Compare commits
13 Commits
cc49ab1655
...
849403a137
Author | SHA1 | Date | |
---|---|---|---|
849403a137 | |||
8d15c9b8b2 | |||
87af1834cf | |||
357583f54d | |||
aa6de76ded | |||
ab3c2d1eb0 | |||
644cf2a358 | |||
5b1ed9925d | |||
4071a50a37 | |||
dd7c51efd8 | |||
8902f4d187 | |||
6666b20464 | |||
c5f288ba1e |
@ -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"
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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
3
go.mod
@ -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
6
go.sum
@ -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
81
internal/auth/login.go
Normal 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(¶ms); 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
31
internal/auth/profile.go
Normal 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
79
internal/auth/refresh.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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(¶ms); 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
99
internal/auth/verify.go
Normal 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
36
internal/cache/mod.go
vendored
Normal 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)
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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
4
redis.conf
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
# Enable ACL
|
||||||
|
user default off
|
||||||
|
user guard on >guard allcommands allkeys
|
25
web/src/api/verify.ts
Normal file
25
web/src/api/verify.ts
Normal 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;
|
||||||
|
};
|
@ -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
|
||||||
|
@ -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" />;
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
Reference in New Issue
Block a user