diff --git a/internal/auth/routes.go b/internal/auth/routes.go index 8fc4ad7..3bb31b6 100644 --- a/internal/auth/routes.go +++ b/internal/auth/routes.go @@ -1,27 +1,22 @@ package auth import ( - "encoding/json" - "fmt" - "log" - "net/http" - "strings" "time" + "gitea.local/admin/hspguard/internal/cache" "gitea.local/admin/hspguard/internal/config" imiddleware "gitea.local/admin/hspguard/internal/middleware" "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/util" - "gitea.local/admin/hspguard/internal/web" "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" ) type AuthHandler struct { - repo *repository.Queries - cfg *config.AppConfig + repo *repository.Queries + cache *cache.Client + cfg *config.AppConfig } func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) { @@ -60,9 +55,10 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) 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{ repo, + cache, cfg, } } @@ -74,166 +70,11 @@ func (h *AuthHandler) RegisterRoutes(api chi.Router) { protected.Use(authMiddleware.Runner) protected.Get("/profile", h.getProfile) + protected.Post("/email", h.requestEmailOtp) + protected.Post("/email/otp", h.confirmOtp) }) r.Post("/login", h.login) 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) - } -} diff --git a/internal/auth/verify.go b/internal/auth/verify.go new file mode 100644 index 0000000..0963ed0 --- /dev/null +++ b/internal/auth/verify.go @@ -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) +}