feat: rt as httponly cookie & add login handler

This commit is contained in:
ae 2025-04-01 22:54:39 +03:00
parent 5de5c8c285
commit 998176c3f9
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
6 changed files with 75 additions and 10 deletions

View File

@ -23,7 +23,6 @@ var (
type Config struct { type Config struct {
JWTSecret string `env:"JWT_SECRET,notEmpty"` JWTSecret string `env:"JWT_SECRET,notEmpty"`
HTTPPort string `env:"HTTP_PORT" envDefault:"8080"`
DBURL string `env:"PG_URL,notEmpty"` DBURL string `env:"PG_URL,notEmpty"`
RunMode string `env:"GO_ENV" envDefault:"production"` RunMode string `env:"GO_ENV" envDefault:"production"`
} }
@ -55,7 +54,7 @@ func main() {
} }
} }
service.Run(conn, config.JWTSecret, config.HTTPPort) service.Run(conn, config.JWTSecret)
} }
func initLogger() { func initLogger() {

View File

@ -1,7 +1,6 @@
package service package service
import ( import (
"fmt"
"net/http" "net/http"
"git.umbrella.haus/ae/notatest/pkg/data" "git.umbrella.haus/ae/notatest/pkg/data"
@ -11,7 +10,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func Run(conn *pgx.Conn, jwtSecret string, httpPort string) error { func Run(conn *pgx.Conn, jwtSecret string) error {
q := data.New(conn) q := data.New(conn)
r := chi.NewRouter() r := chi.NewRouter()
@ -37,7 +36,6 @@ func Run(conn *pgx.Conn, jwtSecret string, httpPort string) error {
r.Mount("/users", usersRouter.Routes()) r.Mount("/users", usersRouter.Routes())
r.Mount("/notes", notesRouter.Routes()) r.Mount("/notes", notesRouter.Routes())
portStr := fmt.Sprintf(":%s", httpPort) log.Info().Msg("Starting server on :8080")
log.Info().Msgf("Starting server on %s", portStr) return http.ListenAndServe(":8080", r)
return http.ListenAndServe(portStr, r)
} }

View File

@ -145,7 +145,21 @@ func (rs tokensResource) RefreshAccessToken(w http.ResponseWriter, r *http.Reque
return return
} }
respondJSON(w, http.StatusOK, tokenPair) // 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,
})
// Return the access token in the response body (it should be stored in browser's memory client-side)
respondJSON(w, http.StatusOK, map[string]string{
"access_token": tokenPair.AccessToken,
})
} }
func (rs tokensResource) HandleLogout(w http.ResponseWriter, r *http.Request) { func (rs tokensResource) HandleLogout(w http.ResponseWriter, r *http.Request) {

View File

@ -144,7 +144,8 @@ func TestRefreshAccessToken_Success(t *testing.T) {
rs.RefreshAccessToken(w, req) rs.RefreshAccessToken(w, req)
assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "access_token", "refresh_token") assert.Contains(t, w.Body.String(), "access_token")
assert.Contains(t, w.Result().Cookies()[0].Name, "refresh_token")
} }
func TestHandleLogout_Success(t *testing.T) { func TestHandleLogout_Success(t *testing.T) {

View File

@ -21,6 +21,7 @@ type UserStore interface {
CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error) CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error)
ListUsers(ctx context.Context) ([]data.User, error) ListUsers(ctx context.Context) ([]data.User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (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 UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error
DeleteUser(ctx context.Context, id uuid.UUID) error DeleteUser(ctx context.Context, id uuid.UUID) error
RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error
@ -35,7 +36,8 @@ func (rs usersResource) Routes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
// Public routes (no tokens required) // Public routes (no tokens required)
r.Post("/", rs.Create) // POST /users - registration/signup 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) // Protected routes (access token required)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
@ -118,6 +120,52 @@ func (rs usersResource) Create(w http.ResponseWriter, r *http.Request) {
}) })
} }
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,
})
// Return the access token in the response body (it should be stored in browser's memory client-side)
respondJSON(w, http.StatusOK, map[string]string{
"access_token": tokenPair.AccessToken,
})
}
func (rs usersResource) List(w http.ResponseWriter, r *http.Request) { func (rs usersResource) List(w http.ResponseWriter, r *http.Request) {
users, err := rs.Users.ListUsers(r.Context()) users, err := rs.Users.ListUsers(r.Context())
if err != nil { if err != nil {

View File

@ -21,6 +21,7 @@ type mockUserStore struct {
CreateUserFunc func(context.Context, data.CreateUserParams) (data.User, error) CreateUserFunc func(context.Context, data.CreateUserParams) (data.User, error)
ListUsersFunc func(context.Context) ([]data.User, error) ListUsersFunc func(context.Context) ([]data.User, error)
GetUserByIDFunc func(context.Context, uuid.UUID) (data.User, error) GetUserByIDFunc func(context.Context, uuid.UUID) (data.User, error)
GetUserByUsernameFunc func(context.Context, string) (data.User, error)
UpdatePasswordFunc func(context.Context, data.UpdatePasswordParams) error UpdatePasswordFunc func(context.Context, data.UpdatePasswordParams) error
DeleteUserFunc func(context.Context, uuid.UUID) error DeleteUserFunc func(context.Context, uuid.UUID) error
RevokeAllUserRefreshTokensFunc func(context.Context, uuid.UUID) error RevokeAllUserRefreshTokensFunc func(context.Context, uuid.UUID) error
@ -38,6 +39,10 @@ func (m *mockUserStore) GetUserByID(ctx context.Context, id uuid.UUID) (data.Use
return m.GetUserByIDFunc(ctx, id) return m.GetUserByIDFunc(ctx, id)
} }
func (m *mockUserStore) GetUserByUsername(ctx context.Context, username string) (data.User, error) {
return m.GetUserByUsernameFunc(ctx, username)
}
func (m *mockUserStore) UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error { func (m *mockUserStore) UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error {
return m.UpdatePasswordFunc(ctx, arg) return m.UpdatePasswordFunc(ctx, arg)
} }