Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
eb8fb4f
add TODO
PiquelChips Jan 27, 2026
4f11536
add user sessions table
PiquelChips Feb 8, 2026
24d6402
add custom jwt claims
PiquelChips Feb 8, 2026
59885c4
update auth service api to have refresh system
PiquelChips Feb 8, 2026
58082e1
update auth handlers for new refresh api
PiquelChips Feb 8, 2026
066ed92
fix build errors
PiquelChips Feb 8, 2026
c39f69e
move some stuff around
PiquelChips Feb 8, 2026
9a98807
finish auth api & fix handlers
PiquelChips Feb 8, 2026
0f9e107
add TODOs
PiquelChips Feb 8, 2026
a642b22
setup refresh token generation and validation
PiquelChips Feb 8, 2026
3e67f01
update todo's and hashing functions
PiquelChips Feb 8, 2026
22f805c
fix build error
PiquelChips Feb 8, 2026
f2d20e1
setup session queries
PiquelChips Feb 8, 2026
b43b5ba
add TODO
PiquelChips Feb 8, 2026
e0f0366
add cookie utils
PiquelChips Feb 8, 2026
ee3e149
add domain env var
PiquelChips Feb 8, 2026
d3c8f47
setup FinishAuth API & finalize token generation
PiquelChips Feb 8, 2026
7d481da
add cookie parsing util
PiquelChips Feb 8, 2026
21055bd
setup refresh
PiquelChips Feb 8, 2026
07c92a3
make sure to verify the refresh token
PiquelChips Feb 8, 2026
ffb467e
fix getting token from request
PiquelChips Feb 8, 2026
dd2cfe8
fix getting user from token to not use DB call
PiquelChips Feb 8, 2026
9533d40
check token expiry in auth middleware
PiquelChips Feb 8, 2026
3771f2f
fix issue with wrong header expiry
PiquelChips Feb 8, 2026
55c1161
fix handling of claims in auth service
PiquelChips Feb 8, 2026
782152c
fix attempting to parse cookies when receiving empty string
PiquelChips Feb 8, 2026
9d2e5fe
fix refresh endpoint conditions
PiquelChips Feb 8, 2026
31293dc
fix redirect URL
PiquelChips Feb 8, 2026
6fce62a
update cors middleware
PiquelChips Feb 8, 2026
185c467
update refresh error handling
PiquelChips Feb 9, 2026
1b21bbf
setup cookie clearing util
PiquelChips Feb 9, 2026
0e4b615
add logout functionality
PiquelChips Feb 9, 2026
9fb6305
setup session deletion queries
PiquelChips Feb 9, 2026
5b81cbf
rename email removal functions
PiquelChips Feb 9, 2026
9f081b6
add ip address util
PiquelChips Feb 9, 2026
58cca1b
setup logging out
PiquelChips Feb 9, 2026
14f6d8d
setup redirect after logout
PiquelChips Feb 9, 2026
22fed7c
fix logout
PiquelChips Feb 9, 2026
218e4d9
fix build errors
PiquelChips Feb 9, 2026
02809cd
update refresh cookie path
PiquelChips Feb 9, 2026
99ff418
fix redirecting
PiquelChips Feb 9, 2026
02954b9
fix options handler
PiquelChips Feb 9, 2026
8342a41
move handlers around
PiquelChips Feb 9, 2026
0bc9b50
setup handlers for sessions management
PiquelChips Feb 9, 2026
82067c3
add session management to auth service
PiquelChips Feb 9, 2026
df3aaf3
update policy for session management
PiquelChips Feb 9, 2026
dd98866
fix build errors
PiquelChips Feb 9, 2026
e399862
fix error when updating accounts
PiquelChips Feb 9, 2026
3b32537
make sure session belongs to user when deleting
PiquelChips Feb 9, 2026
602051c
setup session handlers
PiquelChips Feb 9, 2026
d95b05f
fix getting user form path value
PiquelChips Feb 9, 2026
b42c073
add user session schema
PiquelChips Feb 9, 2026
437d75b
add spec for new routes
PiquelChips Feb 9, 2026
4cb244b
update OpenAPI spec authentication
PiquelChips Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,38 @@ func CreateAuthHandler(userService users.UserService, authService auth.AuthServi
func (h *AuthHandler) createHttpHandler() http.Handler {
handler := http.NewServeMux()

handler.HandleFunc("GET /{provider}", h.handleProviderLogin)
handler.HandleFunc("GET /{provider}/callback", h.handleAuthCallback)
handler.HandleFunc("POST /refresh", h.handleRefresh)
handler.Handle("OPTIONS /refresh", middleware.CreateOptionsHandler("POST"))

handler.HandleFunc("GET /logout", h.handleLogout)
handler.Handle("OPTIONS /logout", middleware.CreateOptionsHandler("GET"))

handler.HandleFunc("GET /{provider}", h.handleProviderLogin)
handler.Handle("OPTIONS /{provider}", middleware.CreateOptionsHandler("GET"))

handler.HandleFunc("GET /{provider}/callback", h.handleAuthCallback)
handler.Handle("OPTIONS /{provider}/callback", middleware.CreateOptionsHandler("GET"))

return handler
}

func (h *AuthHandler) handleRefresh(w http.ResponseWriter, r *http.Request) {
if err := h.authService.Refresh(w, r); err != nil {
errors.HandleError(w, r, err)
return
}
}

func (h *AuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := h.authService.Logout(w, r); err != nil {
errors.HandleError(w, r, err)
return
}

redirectUrl := h.formatRedirectURL("/")
http.Redirect(w, r, redirectUrl, http.StatusTemporaryRedirect)
}

func (h *AuthHandler) handleProviderLogin(w http.ResponseWriter, r *http.Request) {
providerName := r.PathValue("provider")
provider, err := h.authService.GetProvider(providerName)
Expand Down Expand Up @@ -74,16 +97,15 @@ func (h *AuthHandler) handleAuthCallback(w http.ResponseWriter, r *http.Request)
return
}

tokenString, err := h.authService.SignToken(h.authService.GenerateToken(user))
if err != nil {
if err := h.authService.FinishAuth(user, r, w); err != nil {
errors.HandleError(w, r, err)
return
}

redirectUrl := h.formatRedirectURL(r.URL.Query().Get("state"), tokenString)
redirectUrl := h.formatRedirectURL(r.URL.Query().Get("state"))
http.Redirect(w, r, redirectUrl, http.StatusTemporaryRedirect)
}

func (h *AuthHandler) formatRedirectURL(redirectTo string, token string) string {
return fmt.Sprintf("%s?redirectTo=%s&token=%s", config.Envs.AuthCallbackUrl, utils.FormatLocalPathString(redirectTo), token)
func (h *AuthHandler) formatRedirectURL(redirectTo string) string {
return fmt.Sprintf("%s%s", config.Envs.AuthCallbackUrl, utils.FormatLocalPathString(redirectTo))
}
15 changes: 7 additions & 8 deletions api/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,16 +220,15 @@ func (h *EmailHandler) createHttpHandler() http.Handler {
// accounts
handler.HandleFunc("GET /", h.handleListAccounts)
handler.HandleFunc("PUT /", h.handleAddAccount)
handler.Handle("OPTIONS /", middleware.CreateOptionsHandler("GET", "PUT"))

handler.HandleFunc("GET /{email}", h.handleAccountInfo)
handler.HandleFunc("DELETE /{email}", h.handleRemoveAccount)
handler.Handle("OPTIONS /{email}", middleware.CreateOptionsHandler("GET", "DELETE"))

// sharing
handler.HandleFunc("PUT /{email}/share", h.handleShareAccount)
handler.HandleFunc("DELETE /{email}/share", h.handleRemoveAccountShare)

// OPTIONS handlers
handler.Handle("OPTIONS /", middleware.CreateOptionsHandler("GET", "PUT"))
handler.Handle("OPTIONS /{email}", middleware.CreateOptionsHandler("GET", "DELETE"))
handler.Handle("OPTIONS /{email}/share", middleware.CreateOptionsHandler("PUT", "DELETE"))

return handler
Expand Down Expand Up @@ -281,9 +280,9 @@ func (h *EmailHandler) handleListAccounts(w http.ResponseWriter, r *http.Request
return
}

for _, account := range accounts {
account.Username = ""
account.Password = ""
for i := range accounts {
accounts[i].Username = ""
accounts[i].Password = ""
}

data, err := json.Marshal(accounts)
Expand Down Expand Up @@ -462,7 +461,7 @@ func (h *EmailHandler) handleRemoveAccountShare(w http.ResponseWriter, r *http.R
return
}

params := repository.RemoveShareParams{
params := repository.DeleteShareParams{
UserId: sharingUser.ID,
Account: account.ID,
}
Expand Down
10 changes: 5 additions & 5 deletions api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,15 @@ func newSpecBase(handler Handler) Spec {
},
}

securitySchemeName := "bearerAuth"
securitySchemeName := "cookieAuth"
spec.Components = &openapi3.Components{
SecuritySchemes: openapi3.SecuritySchemes{
securitySchemeName: &openapi3.SecuritySchemeRef{
Value: &openapi3.SecurityScheme{
Type: "http",
Scheme: "bearer",
BearerFormat: "JWT",
Description: "Enter your bearer token in the format: Bearer <token>",
Type: "apiKey",
In: "cookie",
Name: "access_token",
Description: "Authentication via access_token cookie. The API also uses a refresh_token cookie for token renewal.",
},
},
},
Expand Down
182 changes: 179 additions & 3 deletions api/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package api

import (
"encoding/json"
"fmt"
"net/http"
"strconv"

"github.com/getkin/kin-openapi/openapi3"
"github.com/piquel-fr/api/config"
Expand Down Expand Up @@ -51,10 +53,20 @@ func (h *UserHandler) getSpec() Spec {
WithProperty("role", openapi3.NewStringSchema()).
WithRequired([]string{"username", "name", "image", "email", "role"})

userSessionSchema := openapi3.NewObjectSchema().
WithProperty("id", openapi3.NewInt32Schema()).
WithProperty("userId", openapi3.NewInt32Schema()).
WithProperty("userAgent", openapi3.NewStringSchema()).
WithProperty("ipAdress", openapi3.NewStringSchema()).
WithProperty("expiresAt", openapi3.NewDateTimeSchema()).
WithProperty("createdAt", openapi3.NewDateTimeSchema()).
WithRequired([]string{"id", "userId", "userAgent", "ipAdress", "expiresAt", "createdAt"})

spec.Components.Schemas = openapi3.Schemas{
"User": &openapi3.SchemaRef{Value: userSchema},
"UpdateUserParams": &openapi3.SchemaRef{Value: updateUserSchema},
"UpdateUserAdminParams": &openapi3.SchemaRef{Value: updateUserAdminSchema},
"UserSession": &openapi3.SchemaRef{Value: userSessionSchema},
}

spec.AddOperation("/self", http.MethodGet, &openapi3.Operation{
Expand Down Expand Up @@ -174,22 +186,86 @@ func (h *UserHandler) getSpec() Spec {
),
})

spec.AddOperation("/{user}/sessions", http.MethodGet, &openapi3.Operation{
Tags: []string{"users", "sessions"},
Summary: "Get user sessions",
Description: "Get the active sessions for the specified user",
OperationID: "get-user-sessions",
Parameters: openapi3.Parameters{
&openapi3.ParameterRef{
Value: &openapi3.Parameter{
Name: "user",
In: "path",
Required: true,
Description: "The username",
Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()},
},
},
},
Responses: openapi3.NewResponses(
openapi3.WithStatus(200, &openapi3.ResponseRef{
Value: openapi3.NewResponse().
WithDescription("User sessions found").
WithJSONSchemaRef(openapi3.NewSchemaRef("", openapi3.NewArraySchema().WithItems(userSessionSchema))),
}),
openapi3.WithStatus(401, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Unauthorized")}),
openapi3.WithStatus(403, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Forbidden")}),
),
})

spec.AddOperation("/{user}/sessions", http.MethodDelete, &openapi3.Operation{
Tags: []string{"users", "sessions"},
Summary: "Delete user sessions",
Description: "Delete all sessions for the user, or a specific one if 'id' query param is provided",
OperationID: "delete-user-sessions",
Parameters: openapi3.Parameters{
&openapi3.ParameterRef{
Value: &openapi3.Parameter{
Name: "user",
In: "path",
Required: true,
Description: "The username",
Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()},
},
},
&openapi3.ParameterRef{
Value: &openapi3.Parameter{
Name: "id",
In: "query",
Required: false,
Description: "Specific session ID to delete",
Schema: &openapi3.SchemaRef{Value: openapi3.NewInt32Schema()},
},
},
},
Responses: openapi3.NewResponses(
openapi3.WithStatus(200, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Session(s) deleted successfully")}),
openapi3.WithStatus(400, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Invalid input")}),
openapi3.WithStatus(401, &openapi3.ResponseRef{Value: openapi3.NewResponse().WithDescription("Unauthorized")}),
),
})

return spec
}

func (h *UserHandler) createHttpHandler() http.Handler {
handler := http.NewServeMux()

handler.HandleFunc("GET /self", h.handleGetSelf)
handler.Handle("OPTIONS /self", middleware.CreateOptionsHandler("GET"))

handler.HandleFunc("GET /{user}", h.handleGetUser)
handler.HandleFunc("PUT /{user}", h.handlePutUser)
handler.HandleFunc("DELETE /{user}", h.handleDeleteUser)
handler.HandleFunc("PUT /{user}/admin", h.handlePutUserAdmin)

handler.Handle("OPTIONS /self", middleware.CreateOptionsHandler("GET"))
handler.Handle("OPTIONS /{user}", middleware.CreateOptionsHandler("GET", "PUT", "DELETE"))

handler.HandleFunc("PUT /{user}/admin", h.handlePutUserAdmin)
handler.Handle("OPTIONS /{user}/admin", middleware.CreateOptionsHandler("PUT"))

handler.HandleFunc("GET /{user}/sessions", h.handleGetUserSessions)
handler.HandleFunc("DELETE /{user}/sessions", h.handleDeleteUserSessions)
handler.Handle("OPTIONS /{user}/sessions", middleware.CreateOptionsHandler("GET", "DELETE"))

return handler
}

Expand Down Expand Up @@ -362,3 +438,103 @@ func (h *UserHandler) handlePutUserAdmin(w http.ResponseWriter, r *http.Request)
}
w.WriteHeader(http.StatusOK)
}

func (h *UserHandler) handleGetUserSessions(w http.ResponseWriter, r *http.Request) {
requester, err := h.userService.GetUserFromContext(r.Context())
if err != nil {
errors.HandleError(w, r, err)
return
}

var user *repository.User
username := r.PathValue("user")
if username == requester.Username {
user = requester
} else {
user, err = h.userService.GetUserByUsername(r.Context(), username)
if err != nil {
errors.HandleError(w, r, err)
return
}
}

if err := h.authService.Authorize(&config.AuthRequest{
User: requester,
Ressource: user,
Actions: []string{auth.ActionViewUserSessions},
Context: r.Context(),
}); err != nil {
errors.HandleError(w, r, err)
return
}

sessions, err := h.authService.GetUserSessions(r.Context(), user.ID)
if err != nil {
errors.HandleError(w, r, err)
return
}

// hide the token hash for security reasons
for i := range sessions {
sessions[i].TokenHash = ""
}

data, err := json.Marshal(sessions)
if err != nil {
errors.HandleError(w, r, err)
return
}

w.Header().Set("Content-Type", "application/json")
w.Write(data)
}

func (h *UserHandler) handleDeleteUserSessions(w http.ResponseWriter, r *http.Request) {
requester, err := h.userService.GetUserFromContext(r.Context())
if err != nil {
errors.HandleError(w, r, err)
return
}

var user *repository.User
username := r.PathValue("user")
if username == requester.Username {
user = requester
} else {
user, err = h.userService.GetUserByUsername(r.Context(), username)
if err != nil {
errors.HandleError(w, r, err)
return
}
}

if err := h.authService.Authorize(&config.AuthRequest{
User: requester,
Ressource: user,
Actions: []string{auth.ActionDeleteUserSessions},
Context: r.Context(),
}); err != nil {
errors.HandleError(w, r, err)
return
}

if idStr := r.URL.Query().Get("id"); idStr != "" {
id, err := strconv.Atoi(idStr)
if err != nil {
errors.HandleError(w, r, errors.NewError(fmt.Sprintf("id %s is not valid integer %s", idStr, err.Error()), http.StatusBadRequest))
return
}
if err := h.authService.DeleteUserSession(r.Context(), user.ID, int32(id)); err != nil {
errors.HandleError(w, r, err)
return
}
w.WriteHeader(http.StatusOK)
return
}

if err := h.authService.DeleteUserSessions(r.Context(), user.ID); err != nil {
errors.HandleError(w, r, err)
return
}
w.WriteHeader(http.StatusOK)
}
2 changes: 2 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
type EnvsConfig struct {
AuthCallbackUrl string
Url string
Domain string
Port string
DBURL string
GithubApiToken string
Expand Down Expand Up @@ -54,6 +55,7 @@ func LoadConfig() {
Envs = EnvsConfig{
AuthCallbackUrl: getEnv("AUTH_CALLBACK"),
Url: getEnv("URL"),
Domain: getEnv("DOMAIN"),
Port: getDefaultEnv("PORT", "80"),
DBURL: getEnv("DB_URL"),
GoogleClientID: getEnv("AUTH_GOOGLE_CLIENT_ID"),
Expand Down
4 changes: 2 additions & 2 deletions database/queries/email.sql
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ FROM "mail_accounts"
LEFT JOIN "mail_share" ON "mail_accounts"."id" = "mail_share"."account"
WHERE "mail_accounts"."ownerId" = $1 OR "mail_share"."userId" = $1;

-- name: RemoveMailAccount :exec
-- name: DeleteMailAccount :exec
DELETE FROM "mail_accounts"
WHERE "id" = $1;

Expand All @@ -38,7 +38,7 @@ INSERT INTO "mail_share" (
)
VALUES ($1, $2, $3);

-- name: RemoveShare :exec
-- name: DeleteShare :exec
DELETE FROM "mail_share"
WHERE "userId" = $1 AND "account" = $2;

Expand Down
Loading