package service import ( "context" "encoding/json" "errors" "net/http" "strconv" "time" "git.umbrella.haus/ae/notatest/pkg/data" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" ) type userCtxKey struct{} // Stripped object that only contains non-critical data type userResponse struct { ID uuid.UUID `json:"id"` Username string `json:"username"` CreatedAt *time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at"` IsAdmin bool `json:"is_admin"` } // Mockable database operations interface type UserStore interface { CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error) ListUsers(ctx context.Context) ([]data.User, error) GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error) GetUserByUsername(ctx context.Context, username string) (data.User, error) UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error DeleteUser(ctx context.Context, id uuid.UUID) error RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error } type usersResource struct { JWTSecret string Users UserStore } func (rs usersResource) Routes() chi.Router { r := chi.NewRouter() // Public routes (no tokens required) r.Post("/", rs.Create) // POST /users - registration/signup r.Post("/login", rs.Login) // POST /users/login - login as existing user // Protected routes (access token required) r.Group(func(r chi.Router) { r.Use(requireAccessToken(rs.JWTSecret)) // Admin only general routes r.Group(func(r chi.Router) { r.Use(adminOnlyMiddleware) r.Get("/", rs.List) // GET /users - list all users }) // User specific routes r.Route("/{id}", func(r chi.Router) { r.Use(userCtx(rs.Users)) // DB -> req. context // Admin routes r.Route("/admin", func(r chi.Router) { r.Use(adminOnlyMiddleware) r.Get("/", rs.Get) // GET /users/admin/{id} - get single user r.Delete("/", rs.AdminDelete) // DELETE /users/admin/{id} - delete user }) // Owner routes r.Route("/owner", func(r chi.Router) { r.Use(ownerOnlyMiddleware) r.Put("/", rs.UpdatePassword) // PUT /users/owner/{id} - update user password r.Delete("/", rs.OwnerDelete) // DELETE /users/owner/{id} - delete user }) }) }) return r } func (rs usersResource) Create(w http.ResponseWriter, r *http.Request) { type request struct { Username string `json:"username"` Password string `json:"password"` } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } normalizedUsername := normalizeUsername(req.Username) if err := validateUsername(normalizedUsername); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } if err := validatePassword(req.Password); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to create user") return } user, err := rs.Users.CreateUser(r.Context(), data.CreateUserParams{ Username: normalizedUsername, PasswordHash: string(hashedPassword), }) if err != nil { if isDuplicateEntry(err) { respondError(w, http.StatusConflict, "Username is already in use") } else { respondError(w, http.StatusInternalServerError, "Failed to create user") } return } respondJSON(w, http.StatusCreated, map[string]string{ "id": user.ID.String(), "username": user.Username, }) } func (rs usersResource) Login(w http.ResponseWriter, r *http.Request) { type request struct { Username string `json:"username"` Password string `json:"password"` } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } user, err := rs.Users.GetUserByUsername(r.Context(), normalizeUsername(req.Username)) if err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") return } tokenPair, err := generateTokenPair(user.ID.String(), user.IsAdmin, rs.JWTSecret) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to generate tokens") return } // Set refresh token in HTTP-only cookie http.SetCookie(w, &http.Cookie{ Name: "refresh_token", Value: tokenPair.RefreshToken, Path: "/", MaxAge: int(refreshTokenDuration.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) // Build response response := map[string]any{ "access_token": tokenPair.AccessToken, } // Include user data if the client has requested it (`?includeUser=true`) if includeUser, _ := strconv.ParseBool(r.URL.Query().Get("includeUser")); includeUser { response["user"] = userResponse{ ID: user.ID, Username: user.Username, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, IsAdmin: user.IsAdmin, } } respondJSON(w, http.StatusOK, response) } func (rs usersResource) List(w http.ResponseWriter, r *http.Request) { users, err := rs.Users.ListUsers(r.Context()) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to retrieve users") return } // Output sanitization var output []map[string]any for _, user := range users { output = append(output, map[string]any{ "id": user.ID, "username": user.Username, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt, }) } respondJSON(w, http.StatusOK, output) } func (rs usersResource) Get(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(userCtxKey{}).(data.User) if !ok { respondError(w, http.StatusNotFound, "User not found") return } respondJSON(w, http.StatusOK, map[string]any{ "id": user.ID, "username": user.Username, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt, }) } func (rs usersResource) UpdatePassword(w http.ResponseWriter, r *http.Request) { type request struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } user, ok := r.Context().Value(userCtxKey{}).(data.User) if !ok { respondError(w, http.StatusNotFound, "User not found") return } // Verify the old password before allowing the update if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") return } if err := validatePassword(req.NewPassword); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to update password") return } if err := rs.Users.UpdatePassword(r.Context(), data.UpdatePasswordParams{ ID: user.ID, PasswordHash: string(hashedPassword), }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to update password") return } if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil { log.Error().Msgf("Failed to revoke refresh tokens: %s", err) } w.WriteHeader(http.StatusNoContent) } func (rs usersResource) AdminDelete(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(userCtxKey{}).(data.User) if !ok { respondError(w, http.StatusNotFound, "User not found") return } if err := rs.Users.DeleteUser(r.Context(), user.ID); err != nil { respondError(w, http.StatusInternalServerError, "Failed to delete user") return } if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil { log.Error().Msgf("Failed to revoke refresh tokens: %s", err) } w.WriteHeader(http.StatusNoContent) } func (rs usersResource) OwnerDelete(w http.ResponseWriter, r *http.Request) { type request struct { Password string `json:"password"` } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } user, ok := r.Context().Value(userCtxKey{}).(data.User) if !ok { respondError(w, http.StatusNotFound, "User not found") return } // Verify the old password before allowing the deletion if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") return } err := rs.Users.DeleteUser(r.Context(), user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to delete user") return } if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil { log.Error().Msgf("Failed to revoke refresh tokens: %s", err) } w.WriteHeader(http.StatusNoContent) } // Check if the given error is a PostgreSQL error for `unique_violation`, i.e. whether an entry // with the given details already exists in the database table. func isDuplicateEntry(err error) bool { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { return pgErr.Code == "23505" } return false }