Compare commits
No commits in common. "42409429e6834130b541a5dab22ccedf225d6217" and "24f4d8023e65e70fdcc2ef8a4f3eef3e98e98afa" have entirely different histories.
42409429e6
...
24f4d8023e
@ -1,5 +1,3 @@
|
|||||||
# shellcheck disable=all
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
PG_USER=""
|
PG_USER=""
|
||||||
PG_PASS=""
|
PG_PASS=""
|
||||||
@ -7,11 +5,4 @@ PG_DB=""
|
|||||||
|
|
||||||
# Server
|
# Server
|
||||||
JWT_SECRET=""
|
JWT_SECRET=""
|
||||||
CSRF_SECRET="" # Should be 32 bytes long
|
LOG_LEVEL="debug"
|
||||||
ADMIN_USERNAME=""
|
|
||||||
ADMIN_PASSWORD=""
|
|
||||||
LOG_LEVEL="info"
|
|
||||||
APP_ENV="production"
|
|
||||||
DOMAIN=""
|
|
||||||
FRONTEND_URL=""
|
|
||||||
|
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,2 @@
|
|||||||
data/
|
|
||||||
TASKS.md
|
TASKS.md
|
||||||
.env
|
.env
|
||||||
|
@ -1,52 +0,0 @@
|
|||||||
services:
|
|
||||||
notatest-psql:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: notatest-psql
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
|
||||||
start_period: 20s
|
|
||||||
interval: 30s
|
|
||||||
retries: 5
|
|
||||||
timeout: 5s
|
|
||||||
volumes:
|
|
||||||
- notatest-data:/var/lib/postgresql/data
|
|
||||||
networks:
|
|
||||||
- notatest
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${PG_USER:-notatest}
|
|
||||||
POSTGRES_PASSWORD: ${PG_PASS:?db password required}
|
|
||||||
POSTGRES_DB: ${PG_DB:-notatest}
|
|
||||||
|
|
||||||
notatest-server:
|
|
||||||
container_name: notatest-server
|
|
||||||
build:
|
|
||||||
context: ${PWD}/server
|
|
||||||
dockerfile: ${PWD}/server/Dockerfile
|
|
||||||
image: notatest-server:latest
|
|
||||||
networks:
|
|
||||||
- notatest
|
|
||||||
ports:
|
|
||||||
- 8080:8080 # !
|
|
||||||
depends_on:
|
|
||||||
notatest-psql:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
JWT_SECRET: ${JWT_SECRET:?jwt secret required}
|
|
||||||
DB_URL: postgres://${PG_USER:-notatest}:${PG_PASS:?db password required}@notatest-psql/${PG_DB:-notatest}?sslmode=disable
|
|
||||||
CSRF_SECRET: ${CSRF_SECRET:?csrf secret required}
|
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:?init admin username required}
|
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?init admin password required}
|
|
||||||
LOG_LEVEL: debug
|
|
||||||
APP_ENV: development
|
|
||||||
DOMAIN: localhost
|
|
||||||
FRONTEND_URL: http://localhost:3000
|
|
||||||
|
|
||||||
networks:
|
|
||||||
notatest:
|
|
||||||
external: false
|
|
||||||
name: notatest
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
notatest-data:
|
|
||||||
name: notatest-data
|
|
@ -10,7 +10,8 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
volumes:
|
volumes:
|
||||||
- ${PWD}/data:/var/lib/postgresql/data
|
# Alternatively mount to disk -> ${PWD}/data
|
||||||
|
- notatest-data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- notatest
|
- notatest
|
||||||
environment:
|
environment:
|
||||||
@ -26,32 +27,35 @@ services:
|
|||||||
image: notatest-server:latest
|
image: notatest-server:latest
|
||||||
networks:
|
networks:
|
||||||
- notatest
|
- notatest
|
||||||
|
ports:
|
||||||
|
- 8080:8080 # TODO: remove forwarding after testing
|
||||||
depends_on:
|
depends_on:
|
||||||
notatest-psql:
|
notatest-psql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
JWT_SECRET: ${JWT_SECRET:?jwt secret required}
|
JWT_SECRET: ${JWT_SECRET:?jwt secret required}
|
||||||
DB_URL: postgres://${PG_USER:-notatest}:${PG_PASS:?db password required}@notatest-psql/${PG_DB:-notatest}?sslmode=disable
|
DB_URL: postgres://${PG_USER:-notatest}:${PG_PASS:?db password required}@notatest-psql/${PG_DB:-notatest}?sslmode=disable
|
||||||
CSRF_SECRET: ${CSRF_SECRET:?csrf secret required}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:?init admin username required}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:?init admin username required}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?init admin password required}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?init admin password required}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
|
||||||
APP_ENV: ${APP_ENV:-production}
|
|
||||||
DOMAIN: ${DOMAIN:-localhost}
|
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173}
|
|
||||||
|
|
||||||
notatest-web:
|
# notatest-web:
|
||||||
build: ${PWD}/web
|
# build: ${PWD}/web
|
||||||
image: notatest/web
|
# image: notatest/web
|
||||||
container_name: notatest-web
|
# container_name: notatest-web
|
||||||
networks:
|
# networks:
|
||||||
- notatest
|
# - notatest
|
||||||
ports:
|
# ports:
|
||||||
- 3000:80 # Defined in nginx.conf
|
# - 3030:3030
|
||||||
depends_on:
|
# depends_on:
|
||||||
- notatest-server
|
# notatest-psql:
|
||||||
|
# condition: service_healthy
|
||||||
|
# notatest-server:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
notatest:
|
notatest:
|
||||||
external: false
|
external: false
|
||||||
name: notatest
|
name: notatest
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
notatest-data:
|
||||||
|
@ -3,4 +3,4 @@
|
|||||||
# Delete all Docker artifacts (e.g. DB) from previous test runs
|
# Delete all Docker artifacts (e.g. DB) from previous test runs
|
||||||
|
|
||||||
docker stop notatest-server notatest-psql && docker rm -f notatest-server notatest-psql
|
docker stop notatest-server notatest-psql && docker rm -f notatest-server notatest-psql
|
||||||
docker volume rm -f notatest-data
|
docker volume rm -f notatest_notatest-data
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
DEV_COMPOSE_FILE="docker-compose-dev-backend.yml"
|
|
||||||
|
|
||||||
[ "$( docker container inspect -f '{{.State.Status}}' notatest-psql )" = "running" ] || docker compose up notatest-psql -d
|
[ "$( docker container inspect -f '{{.State.Status}}' notatest-psql )" = "running" ] || docker compose up notatest-psql -d
|
||||||
|
|
||||||
# Shutdown, rebuild, & restart
|
# Shutdown, rebuild, & restart
|
||||||
docker compose stop notatest-server && docker compose rm -f notatest-server
|
docker compose stop notatest-server && docker compose rm -f notatest-server
|
||||||
docker compose -f $DEV_COMPOSE_FILE build
|
docker compose build
|
||||||
docker compose -f $DEV_COMPOSE_FILE up notatest-server
|
docker compose up notatest-server
|
@ -5,11 +5,9 @@ go 1.24.1
|
|||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v10 v10.0.0
|
github.com/caarlos0/env/v10 v10.0.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/go-chi/cors v1.2.1
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/csrf v1.7.2
|
|
||||||
github.com/jackc/pgx/v5 v5.7.4
|
github.com/jackc/pgx/v5 v5.7.4
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
@ -19,7 +17,6 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
@ -22,8 +22,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@ -35,14 +33,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
|
|||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
|
||||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
@ -16,7 +16,6 @@ import (
|
|||||||
"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"
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/csrf"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@ -80,9 +79,9 @@ type UserStore interface {
|
|||||||
// (especially in production) the `UserStore` and `TokenStore` will point to the same database
|
// (especially in production) the `UserStore` and `TokenStore` will point to the same database
|
||||||
// handler, but for code readability they should be kept in separate structs.
|
// handler, but for code readability they should be kept in separate structs.
|
||||||
type authResource struct {
|
type authResource struct {
|
||||||
Config SvcConfig
|
JWTSecret string
|
||||||
Users UserStore
|
Users UserStore
|
||||||
Tokens TokenStore
|
Tokens TokenStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs authResource) Routes() chi.Router {
|
func (rs authResource) Routes() chi.Router {
|
||||||
@ -94,9 +93,9 @@ func (rs authResource) Routes() chi.Router {
|
|||||||
|
|
||||||
// Protected routes (access token required)
|
// Protected routes (access token required)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(requireAccessToken(rs.Config.JWTSecret)) // JWT claims -> ctx.
|
r.Use(requireAccessToken(rs.JWTSecret)) // JWT claims -> ctx.
|
||||||
r.Get("/me", rs.Get) // GET /auth/me - current user data
|
r.Get("/me", rs.Get) // GET /auth/me - current user data
|
||||||
r.Post("/logout", rs.Logout) // POST /auth/logout - revoke all refresh cookies
|
r.Post("/logout", rs.Logout) // POST /auth/logout - revoke all refresh cookies
|
||||||
|
|
||||||
// Owner routes
|
// Owner routes
|
||||||
r.Route("/owner", func(r chi.Router) {
|
r.Route("/owner", func(r chi.Router) {
|
||||||
@ -116,18 +115,9 @@ func (rs authResource) Routes() chi.Router {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Protected routes (refresh token required)
|
// Protected routes (refresh token required)
|
||||||
r.Route("/cookie", func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
// The refresh token httpOnly cookie is restricted to `/api/auth/cookie`, which is why this
|
r.Use(requireRefreshToken(rs.JWTSecret))
|
||||||
// is the only endpoint where CSRF must be taken into account (HTTPS requirement disabled
|
r.Post("/refresh", rs.RefreshAccessToken) // POST /auth/refresh - convert refresh token to new token pair
|
||||||
// for local development)
|
|
||||||
if rs.Config.IsProd {
|
|
||||||
r.Use(csrf.Protect([]byte(rs.Config.CSRFSecret)))
|
|
||||||
} else {
|
|
||||||
r.Use(csrf.Protect([]byte(rs.Config.CSRFSecret), csrf.Secure(false)))
|
|
||||||
}
|
|
||||||
r.Use(requireRefreshToken(rs.Config.JWTSecret))
|
|
||||||
r.Get("/csrf", rs.GetCSRFToken) // GET /auth/cookie/csrf - get a new CSRF token in response headers
|
|
||||||
r.Post("/refresh", rs.RefreshAccessToken) // POST /auth/cookie/refresh - convert refresh token to new token pair
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@ -224,12 +214,12 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Set refresh token into a httpOnly cookie
|
// Set refresh token into a httpOnly cookie
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "notatest.refresh_token",
|
Name: "refresh_token",
|
||||||
Value: tokenPair.RefreshToken,
|
Value: tokenPair.RefreshToken,
|
||||||
Path: "/api/auth/cookie",
|
Path: "/",
|
||||||
MaxAge: int(refreshTokenDuration.Seconds()),
|
MaxAge: int(refreshTokenDuration.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: rs.Config.IsProd,
|
Secure: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -403,7 +393,7 @@ func (rs authResource) AdminDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ("refresh"/"access"). Stores a SHA256 hash of the refresh token into the database for further
|
// ("refresh"/"access"). Stores a SHA256 hash of the refresh token into the database for further
|
||||||
// token rotations.
|
// token rotations.
|
||||||
func (rs authResource) GenerateTokenPair(ctx context.Context, userID uuid.UUID, isAdmin bool) (*tokenPair, error) {
|
func (rs authResource) GenerateTokenPair(ctx context.Context, userID uuid.UUID, isAdmin bool) (*tokenPair, error) {
|
||||||
tokenPair, err := generateTokenPair(userID.String(), isAdmin, rs.Config.JWTSecret)
|
tokenPair, err := generateTokenPair(userID.String(), isAdmin, rs.JWTSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -452,14 +442,6 @@ func (rs authResource) ValidateRefreshToken(ctx context.Context, token string) (
|
|||||||
return &dbToken, nil
|
return &dbToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler for returning the CSRF token in the `X-CSRF-Token` header. Notably this request doesn't
|
|
||||||
// need to contain a valid CSRF token as it uses "safe" (non-mutating) method GET, which means CSRF
|
|
||||||
// won't be enforced.
|
|
||||||
func (rs authResource) GetCSRFToken(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handler for performing a token rotation, i.e. invalidating the given refresh token (each refresh
|
// Handler for performing a token rotation, i.e. invalidating the given refresh token (each refresh
|
||||||
// token is a single use utility) and exchanging it for a new pair of refresh and access tokens.
|
// token is a single use utility) and exchanging it for a new pair of refresh and access tokens.
|
||||||
func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request) {
|
func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -504,12 +486,12 @@ func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
// Set refresh token into a httpOnly cookie
|
// Set refresh token into a httpOnly cookie
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "notatest.refresh_token",
|
Name: "refresh_token",
|
||||||
Value: tokenPair.RefreshToken,
|
Value: tokenPair.RefreshToken,
|
||||||
Path: "/api/auth/cookie",
|
Path: "/",
|
||||||
MaxAge: int(refreshTokenDuration.Seconds()),
|
MaxAge: int(refreshTokenDuration.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: rs.Config.IsProd,
|
Secure: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -538,12 +520,12 @@ func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Clear the refresh token cookie
|
// Clear the refresh token cookie
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "notatest.refresh_token",
|
Name: "refresh_token",
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/api/auth/cookie",
|
Path: "/",
|
||||||
MaxAge: 0, // Expires immediately
|
MaxAge: 0, // Expires immediately
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: rs.Config.IsProd,
|
Secure: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -555,30 +537,6 @@ func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JWT claims (`userClaims`) from the request's context, and perform a database lookup based
|
|
||||||
// on `Subject` (after parsing it to `uuid.UUID`) to fetch the corresponding user's data.
|
|
||||||
func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request) *data.User {
|
|
||||||
claims, ok := r.Context().Value(userCtxKey{}).(*userClaims)
|
|
||||||
if !ok {
|
|
||||||
respondError(w, http.StatusUnauthorized, "Unauthorized")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := uuid.Parse(claims.Subject)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, "Invalid user ID")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := rs.Users.GetUserByID(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusNotFound, "User not found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &user
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function for generating the initial administrator level account if one doesn't already
|
// Helper function for generating the initial administrator level account if one doesn't already
|
||||||
// exists in the database.
|
// exists in the database.
|
||||||
func CreateAdminIfNotExists(ctx context.Context, q *data.Queries, username, password string) error {
|
func CreateAdminIfNotExists(ctx context.Context, q *data.Queries, username, password string) error {
|
||||||
@ -632,7 +590,7 @@ func getTokenFromHeader(r *http.Request) (string, error) {
|
|||||||
|
|
||||||
// Parse the JWT token from the request's cookies (httpOnly cookie).
|
// Parse the JWT token from the request's cookies (httpOnly cookie).
|
||||||
func getTokenFromCookie(r *http.Request) (string, error) {
|
func getTokenFromCookie(r *http.Request) (string, error) {
|
||||||
cookie, err := r.Cookie("notatest.refresh_token")
|
cookie, err := r.Cookie("refresh_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -678,6 +636,30 @@ func generateTokenPair(userID string, isAdmin bool, jwtSecret string) (*tokenPai
|
|||||||
return &tokenPair{AccessToken: t, RefreshToken: rt}, nil
|
return &tokenPair{AccessToken: t, RefreshToken: rt}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse JWT claims (`userClaims`) from the request's context, and perform a database lookup based
|
||||||
|
// on `Subject` (after parsing it to `uuid.UUID`) to fetch the corresponding user's data.
|
||||||
|
func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request) *data.User {
|
||||||
|
claims, ok := r.Context().Value(userCtxKey{}).(*userClaims)
|
||||||
|
if !ok {
|
||||||
|
respondError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := uuid.Parse(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid user ID")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := rs.Users.GetUserByID(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "User not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the given error is a PostgreSQL error for `unique_violation` (error code 23505), i.e.
|
// Check if the given error is a PostgreSQL error for `unique_violation` (error code 23505), i.e.
|
||||||
// whether an entry with the given details already exists in the database table.
|
// whether an entry with the given details already exists in the database table.
|
||||||
func isDuplicateEntry(err error) bool {
|
func isDuplicateEntry(err error) bool {
|
||||||
|
@ -210,7 +210,7 @@ func loggerMiddleware(log *zerolog.Logger) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log a regular HTTP request with some metadata
|
// Log a regular HTTP request with some metadata
|
||||||
log.Debug().
|
log.Info().
|
||||||
Str("type", "access").
|
Str("type", "access").
|
||||||
Timestamp().
|
Timestamp().
|
||||||
Fields(map[string]any{
|
Fields(map[string]any{
|
||||||
|
@ -110,9 +110,8 @@ func TestRequireRTMiddleware(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/", nil)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
if tc.token != "" {
|
if tc.token != "" {
|
||||||
req.AddCookie(&http.Cookie{
|
req.AddCookie(&http.Cookie{
|
||||||
Name: "notatest.refresh_token",
|
Name: "refresh_token",
|
||||||
Value: tc.token,
|
Value: tc.token,
|
||||||
HttpOnly: true,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,15 +38,15 @@ type NoteStore interface {
|
|||||||
|
|
||||||
// Chi HTTP router for notes related CRUD actions.
|
// Chi HTTP router for notes related CRUD actions.
|
||||||
type notesResource struct {
|
type notesResource struct {
|
||||||
Config SvcConfig
|
JWTSecret string
|
||||||
Notes NoteStore
|
Notes NoteStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs notesResource) Routes() chi.Router {
|
func (rs notesResource) Routes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(requireAccessToken(rs.Config.JWTSecret)) // JWT claims -> ctx.
|
r.Use(requireAccessToken(rs.JWTSecret)) // JWT claims -> ctx.
|
||||||
|
|
||||||
r.Post("/", rs.Create) // POST /notes - create new note
|
r.Post("/", rs.Create) // POST /notes - create new note
|
||||||
r.Get("/", rs.ListMetadata) // GET /notes - get all notes (metadata + titles)
|
r.Get("/", rs.ListMetadata) // GET /notes - get all notes (metadata + titles)
|
||||||
|
@ -6,61 +6,27 @@ import (
|
|||||||
"git.umbrella.haus/ae/notatest/internal/data"
|
"git.umbrella.haus/ae/notatest/internal/data"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SvcConfig struct {
|
func Run(conn *pgx.Conn, q *data.Queries, jwtSecret string) error {
|
||||||
JWTSecret string
|
|
||||||
CSRFSecret string
|
|
||||||
IsProd bool
|
|
||||||
Domain string
|
|
||||||
FrontendURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sc *SvcConfig) allowedOrigins() []string {
|
|
||||||
var allowed []string
|
|
||||||
if sc.IsProd {
|
|
||||||
allowed = []string{sc.FrontendURL}
|
|
||||||
} else {
|
|
||||||
allowed = []string{"http://localhost:3000"}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().Msgf("CORS allowedOrigins: %v", allowed)
|
|
||||||
|
|
||||||
return allowed
|
|
||||||
}
|
|
||||||
|
|
||||||
func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
if !config.IsProd {
|
|
||||||
log.Warn().Msg("Running in *INSECURE* development mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
authRouter := authResource{
|
authRouter := authResource{
|
||||||
Config: config,
|
JWTSecret: jwtSecret,
|
||||||
Users: q,
|
Users: q,
|
||||||
Tokens: q,
|
Tokens: q,
|
||||||
}
|
}
|
||||||
notesRouter := notesResource{
|
notesRouter := notesResource{
|
||||||
Config: config,
|
JWTSecret: jwtSecret,
|
||||||
Notes: q,
|
Notes: q,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global middlewares
|
// Global middlewares
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
r.Use(loggerMiddleware(&log.Logger))
|
r.Use(loggerMiddleware(&log.Logger))
|
||||||
r.Use(cors.Handler(cors.Options{
|
|
||||||
AllowedOrigins: config.allowedOrigins(),
|
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
|
||||||
ExposedHeaders: []string{"List"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
MaxAge: 300,
|
|
||||||
}))
|
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.AllowContentType("application/json"))
|
r.Use(middleware.AllowContentType("application/json"))
|
||||||
|
|
||||||
@ -69,15 +35,8 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
|
|||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Mount("/auth", authRouter.Routes())
|
r.Mount("/auth", authRouter.Routes())
|
||||||
r.Mount("/notes", notesRouter.Routes())
|
r.Mount("/notes", notesRouter.Routes())
|
||||||
r.Get("/ping", ping)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Info().Msg("Starting server on :8080")
|
log.Info().Msg("Starting server on :8080")
|
||||||
return http.ListenAndServe(":8080", r)
|
return http.ListenAndServe(":8080", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ping(w http.ResponseWriter, r *http.Request) {
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
|
||||||
"message": "pong",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
@ -21,20 +21,15 @@ import (
|
|||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
var (
|
var (
|
||||||
config Config
|
config Config
|
||||||
svcConfig service.SvcConfig
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
JWTSecret string `env:"JWT_SECRET,notEmpty"`
|
JWTSecret string `env:"JWT_SECRET,notEmpty"`
|
||||||
CSRFSecret string `env:"CSRF_SECRET,notEmpty"`
|
|
||||||
DatabaseURL string `env:"DB_URL,notEmpty"`
|
DatabaseURL string `env:"DB_URL,notEmpty"`
|
||||||
|
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||||
AdminUsername string `env:"ADMIN_USERNAME,notEmpty,unset"`
|
AdminUsername string `env:"ADMIN_USERNAME,notEmpty,unset"`
|
||||||
AdminPassword string `env:"ADMIN_PASSWORD,notEmpty,unset"`
|
AdminPassword string `env:"ADMIN_PASSWORD,notEmpty,unset"`
|
||||||
Domain string `env:"DOMAIN" envDefault:"localhost"`
|
|
||||||
FrontendURL string `env:"FRONTEND_URL" envDefault:"http://localhost:5173"`
|
|
||||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
|
||||||
AppEnv string `env:"APP_ENV" envDefault:"production"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -43,15 +38,6 @@ func init() {
|
|||||||
log.Fatal().Err(err).Msg("Failed to parse environment variables")
|
log.Fatal().Err(err).Msg("Failed to parse environment variables")
|
||||||
}
|
}
|
||||||
initLogger()
|
initLogger()
|
||||||
|
|
||||||
svcConfig = service.SvcConfig{
|
|
||||||
JWTSecret: config.JWTSecret,
|
|
||||||
CSRFSecret: config.CSRFSecret,
|
|
||||||
IsProd: config.AppEnv == "production",
|
|
||||||
Domain: config.Domain,
|
|
||||||
FrontendURL: config.FrontendURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().Msg("Initialization completed")
|
log.Debug().Msg("Initialization completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,7 +71,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Msg("Migrations applied succesfully, proceeding to HTTP server startup")
|
log.Info().Msg("Migrations applied succesfully, proceeding to HTTP server startup")
|
||||||
service.Run(conn, q, svcConfig)
|
service.Run(conn, q, config.JWTSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initLogger() {
|
func initLogger() {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user