Compare commits

...

7 Commits

6 changed files with 170 additions and 73 deletions

View File

@ -40,7 +40,7 @@ services:
LOG_LEVEL: debug LOG_LEVEL: debug
APP_ENV: development APP_ENV: development
DOMAIN: localhost DOMAIN: localhost
FRONTEND_URL: http://localhost:3000 FRONTEND_URL: http://localhost
networks: networks:
notatest: notatest:

View File

@ -25,6 +25,10 @@ import (
const ( const (
accessTokenDuration = 15 * time.Minute accessTokenDuration = 15 * time.Minute
refreshTokenDuration = 7 * 24 * time.Hour refreshTokenDuration = 7 * 24 * time.Hour
authCookieName = "notatest.refresh_token"
viewCookieName = "notatest.expires_at"
authCookiePath = "/api/auth/cookie"
) )
var ( var (
@ -222,16 +226,7 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
// Set refresh token into a httpOnly cookie rs.setAuthCookies(w, tokenPair, false)
http.SetCookie(w, &http.Cookie{
Name: "notatest.refresh_token",
Value: tokenPair.RefreshToken,
Path: "/api/auth/cookie",
MaxAge: int(refreshTokenDuration.Seconds()),
HttpOnly: true,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
// Build response // Build response
response := map[string]any{ response := map[string]any{
@ -327,16 +322,7 @@ func (rs authResource) UpdatePassword(w http.ResponseWriter, r *http.Request) {
return return
} }
// Set refresh token into a httpOnly cookie rs.setAuthCookies(w, tokenPair, false)
http.SetCookie(w, &http.Cookie{
Name: "notatest.refresh_token",
Value: tokenPair.RefreshToken,
Path: "/api/auth/cookie",
MaxAge: int(refreshTokenDuration.Seconds()),
HttpOnly: true,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
response := map[string]any{ response := map[string]any{
"access_token": tokenPair.AccessToken, "access_token": tokenPair.AccessToken,
@ -383,15 +369,7 @@ func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) {
} }
// Clear the refresh token cookie // Clear the refresh token cookie
http.SetCookie(w, &http.Cookie{ rs.setAuthCookies(w, nil, true)
Name: "notatest.refresh_token",
Value: "",
Path: "/api/auth/cookie",
MaxAge: 0, // Expires immediately
HttpOnly: true,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil { if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil {
log.Error().Msgf("Failed to revoke refresh tokens: %s", err) log.Error().Msgf("Failed to revoke refresh tokens: %s", err)
@ -555,16 +533,7 @@ func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request
return return
} }
// Set refresh token into a httpOnly cookie rs.setAuthCookies(w, tokenPair, false)
http.SetCookie(w, &http.Cookie{
Name: "notatest.refresh_token",
Value: tokenPair.RefreshToken,
Path: "/api/auth/cookie",
MaxAge: int(refreshTokenDuration.Seconds()),
HttpOnly: true,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
// Return the access token in the response body (it should be stored in browser's memory client-side) // 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{ respondJSON(w, http.StatusOK, map[string]string{
@ -590,15 +559,7 @@ 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{ rs.setAuthCookies(w, nil, true)
Name: "notatest.refresh_token",
Value: "",
Path: "/api/auth/cookie",
MaxAge: 0, // Expires immediately
HttpOnly: true,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
if err := rs.Tokens.RevokeAllUserRefreshTokens(r.Context(), userID); err != nil { if err := rs.Tokens.RevokeAllUserRefreshTokens(r.Context(), userID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to logout") respondError(w, http.StatusInternalServerError, "Failed to logout")
@ -632,6 +593,48 @@ func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request)
return &user return &user
} }
func (rs authResource) setAuthCookies(w http.ResponseWriter, tokenPair *tokenPair, clearCookies bool) {
expirationTime := time.Now().Add(refreshTokenDuration)
expirationUnix := strconv.FormatInt(expirationTime.Unix(), 10)
log.Debug().Msgf("Setting authentication cookies (clearCookies: %t)", clearCookies)
var maxAge int
var value string
if clearCookies {
maxAge = 0 // Expires immediately
value = ""
} else {
maxAge = int(refreshTokenDuration.Seconds())
value = tokenPair.RefreshToken
}
// The actual auth cookie is httpOnly, i.e. not viewable by the client
http.SetCookie(w, &http.Cookie{
Name: authCookieName,
Value: value,
Domain: rs.Config.Domain,
Path: authCookiePath,
MaxAge: maxAge,
HttpOnly: true,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
// The information cookie can be used by the client to check how long it'll take until the
// actual auth cookie expires (notably `HttpOnly: false` is a must)
http.SetCookie(w, &http.Cookie{
Name: viewCookieName,
Value: expirationUnix,
Domain: rs.Config.Domain,
Path: authCookiePath,
MaxAge: maxAge,
HttpOnly: false,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
}
// 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 {

View File

@ -56,8 +56,8 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
r.Use(cors.Handler(cors.Options{ r.Use(cors.Handler(cors.Options{
AllowedOrigins: config.allowedOrigins(), AllowedOrigins: config.allowedOrigins(),
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Csrf-Token"},
ExposedHeaders: []string{"List"}, ExposedHeaders: []string{"List", "X-Csrf-Token"},
AllowCredentials: true, AllowCredentials: true,
MaxAge: 300, MaxAge: 300,
})) }))

View File

@ -3,12 +3,12 @@
:root { :root {
--light-background: #f5f5f5; --light-background: #f5f5f5;
--light-foreground: #e0e0e0; --light-foreground: #e0e0e0;
--light-accent: #1e2400; --light-accent: #303052;
--light-text: rgb(34, 40, 49); --light-text: rgb(34, 40, 49);
--dark-background: #181818; --dark-background: #181818;
--dark-foreground: #222222; --dark-foreground: #222222;
--dark-accent: #e0e2c2; --dark-accent: #bebff7;
--dark-text: rgb(238, 238, 238); --dark-text: rgb(238, 238, 238);
} }
@ -164,16 +164,25 @@
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8; @apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
} }
/* Sidebar specific classes */ /* Sidebar */
.sidebar { .sidebar {
@apply fixed flex h-full w-64 flex-col overflow-hidden bg-[var(--light-foreground)] transition-all duration-300; @apply fixed flex h-full w-64 flex-col overflow-hidden bg-[var(--light-foreground)] transition-all duration-300;
} }
.dark .sidebar {
@apply bg-[var(--dark-foreground)];
}
.sidebar-header, .sidebar-header,
.sidebar-footer { .sidebar-footer {
@apply border-[var(--light-text)]/20 p-4; @apply border-[var(--light-text)]/20 p-4;
} }
.dark .sidebar-header,
.dark .sidebar-footer {
@apply border-[var(--dark-text)]/20;
}
.sidebar-header { .sidebar-header {
@apply border-b; @apply border-b;
} }
@ -186,60 +195,111 @@
@apply cursor-pointer p-3 transition-colors hover:bg-[var(--light-background)]; @apply cursor-pointer p-3 transition-colors hover:bg-[var(--light-background)];
} }
.dark .sidebar-item {
@apply hover:bg-[var(--dark-background)];
}
.sidebar-item-active { .sidebar-item-active {
@apply bg-[var(--light-background)]; @apply bg-[var(--light-background)];
} }
.dark .sidebar-item-active {
@apply bg-[var(--dark-background)];
}
.sidebar-item-text { .sidebar-item-text {
@apply text-xs text-[var(--light-text)]/60; @apply text-xs text-[var(--light-text)]/60;
} }
.dark .sidebar-item-text {
@apply text-[var(--dark-text)]/60;
}
.sidebar-divider { .sidebar-divider {
@apply divide-y divide-[var(--light-text)]/20; @apply divide-y divide-[var(--light-text)]/20;
} }
/* Note editor specific classes */ .dark .sidebar-divider {
@apply divide-[var(--dark-text)]/20;
}
/* Note editor */
.note-title-input { .note-title-input {
@apply w-full border-b border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl font-bold focus:border-[var(--light-accent)]; @apply w-full border-b border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl font-bold focus:border-[var(--light-accent)];
} }
.dark .note-title-input {
@apply border-[var(--dark-text)]/20 focus:border-[var(--dark-accent)];
}
.note-char-count { .note-char-count {
@apply mt-1 text-xs text-[var(--light-text)]/60; @apply mt-1 text-xs text-[var(--light-text)]/60;
} }
.dark .note-char-count {
@apply text-[var(--dark-text)]/60;
}
.note-textarea { .note-textarea {
@apply h-full min-h-[300px] w-full resize-none bg-[var(--light-background)] p-4 font-mono; @apply h-full min-h-[300px] w-full resize-none bg-[var(--light-background)] p-4 font-mono;
} }
/* Settings modal specific classes */ .dark .note-textarea {
@apply bg-[var(--dark-background)];
}
/* Settings modal */
.modal-backdrop { .modal-backdrop {
/* @apply bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black; */ @apply fixed inset-0 z-50 flex items-center justify-center backdrop-blur-xs;
@apply fixed inset-0 z-50 flex items-center justify-center bg-black;
} }
.modal-content { .modal-content {
@apply mx-4 max-h-[90vh] w-full max-w-md overflow-y-auto rounded-lg bg-[var(--light-background)] shadow-lg; @apply mx-4 max-h-[90vh] w-full max-w-md overflow-y-auto rounded-lg bg-[var(--light-background)] shadow-lg;
} }
.dark .modal-content {
@apply bg-[var(--dark-background)];
}
.modal-section { .modal-section {
@apply border-b border-[var(--light-text)]/20 p-4; @apply border-b border-[var(--light-text)]/20 p-4;
} }
.modal-close-button { .dark .modal-section {
@apply text-[var(--light-text)]/60 hover:text-[var(--light-text)]; @apply border-[var(--dark-text)]/20;
} }
/* Loading spinner specific classes */ .modal-close-button {
@apply text-[var(--light-background)]/60 hover:text-[var(--light-background)];
}
.dark .modal-close-button {
@apply text-[var(--dark-background)]/60 hover:text-[var(--dark-background)];
}
/* Loading spinner */
.spinner-dot { .spinner-dot {
@apply bg-[var(--light-accent)]; @apply bg-[var(--light-accent)];
} }
/* Main layout classes */ .dark .spinner-dot {
@apply bg-[var(--dark-accent)];
}
/* Main layout */
.main-header { .main-header {
@apply flex items-center justify-between bg-[var(--light-foreground)] p-4 shadow-sm; @apply flex items-center justify-between bg-[var(--light-foreground)] p-4 shadow-sm;
} }
.dark .main-header {
@apply flex items-center justify-between bg-[var(--dark-foreground)] p-4 shadow-sm;
}
.main-content { .main-content {
@apply flex-1 overflow-auto p-6; @apply flex-1 overflow-auto bg-[var(--light-background)] p-6;
}
.dark .main-content {
@apply bg-[var(--dark-background)];
} }
} }

View File

@ -56,6 +56,7 @@ export const isPending: Writable<boolean> = writable(false)
export const cError: Writable<string | null> = writable(null) export const cError: Writable<string | null> = writable(null)
class ApiClient { class ApiClient {
private viewCookieName: string
private baseUrl: string private baseUrl: string
private lastAtUpdate = new Date(0) private lastAtUpdate = new Date(0)
private lastCsrfUpdate = new Date(0) private lastCsrfUpdate = new Date(0)
@ -63,6 +64,7 @@ class ApiClient {
constructor(baseUrl: string) { constructor(baseUrl: string) {
this.baseUrl = baseUrl this.baseUrl = baseUrl
this.viewCookieName = "notatest.expires_at"
} }
private async handleRequest<T>( private async handleRequest<T>(
@ -101,7 +103,7 @@ class ApiClient {
// but it's still good to have as a fallback // but it's still good to have as a fallback
try { try {
console.log("unexpected 401 caught, attempting refresh") console.log("unexpected 401 caught, attempting refresh")
await this.refreshAccessToken() await this.checkAndRefreshAccessToken()
} catch (err) { } catch (err) {
console.log("refresh attempt not successful") console.log("refresh attempt not successful")
await this.handleLocalLogout() await this.handleLocalLogout()
@ -120,7 +122,11 @@ class ApiClient {
} }
private async checkAndRefreshAccessToken(): Promise<void> { private async checkAndRefreshAccessToken(): Promise<void> {
if (this.refreshInProgress) return // Notably we must check whether we even have an authentication cookie present
// as that's obviously required for completing the token rotation procedure
if (this.refreshInProgress || !this.isAuthCookieValid) {
return
}
const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime() const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime()
const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF
@ -141,10 +147,10 @@ class ApiClient {
async () => { async () => {
const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, { const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, {
method: "POST", method: "POST",
credentials: "include",
headers: { headers: {
...(await this.getCsrfHeader()) ...(await this.getCsrfHeader())
} },
credentials: "include"
}) })
if (!response.ok) { if (!response.ok) {
@ -166,11 +172,12 @@ class ApiClient {
const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF
if (!token || needsRefresh) { if (!token || needsRefresh) {
console.log("refreshing csrf token")
await this.refreshCsrfToken() await this.refreshCsrfToken()
token = get(csrfToken) token = get(csrfToken)
} }
return { "X-CSRF-Token": token || "" } return { "X-Csrf-Token": token || "" }
} }
private async refreshCsrfToken(): Promise<void> { private async refreshCsrfToken(): Promise<void> {
@ -185,7 +192,7 @@ class ApiClient {
throw new Error("Fetching CSRF token failed.") throw new Error("Fetching CSRF token failed.")
} }
const newToken = response.headers.get("X-CSRF-Token") const newToken = response.headers.get("X-Csrf-Token")
csrfToken.set(newToken) csrfToken.set(newToken)
this.lastCsrfUpdate = new Date() this.lastCsrfUpdate = new Date()
}, },
@ -207,6 +214,27 @@ class ApiClient {
return { Authorization: `Bearer ${token}` } return { Authorization: `Bearer ${token}` }
} }
private isAuthCookieValid(): boolean {
const now = new Date()
const cookies = document.cookie.split(";")
for (let cookie of cookies) {
const [name, value] = cookie.trim().split("=")
if (name === this.viewCookieName) {
const expirationTimestamp = parseInt(value, 10)
if (!isNaN(expirationTimestamp)) {
const expirationDate = new Date(expirationTimestamp * 1000)
console.log(`auth cookie expirationDate: ${expirationDate}`)
return now < expirationDate
}
}
}
// Fallback when the cookie can't be found
return false
}
public async register(username: string, password: string): Promise<void> { public async register(username: string, password: string): Promise<void> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
@ -237,7 +265,8 @@ class ApiClient {
const response = await fetch(`${this.baseUrl}/auth/login?${params}`, { const response = await fetch(`${this.baseUrl}/auth/login?${params}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password }),
credentials: "include"
}) })
const { access_token: token, user } = await this.handleResponse<{ const { access_token: token, user } = await this.handleResponse<{
@ -263,7 +292,8 @@ class ApiClient {
method: "POST", method: "POST",
headers: { headers: {
...this.getAuthHeader() ...this.getAuthHeader()
} },
credentials: "include"
}) })
if (response.status === 204) { if (response.status === 204) {
@ -328,7 +358,8 @@ class ApiClient {
const response = await fetch(`${this.baseUrl}/auth/owner`, { const response = await fetch(`${this.baseUrl}/auth/owner`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
...this.getAuthHeader() ...this.getAuthHeader(),
credentials: "include"
}, },
body: JSON.stringify({ password }) body: JSON.stringify({ password })
}) })
@ -414,6 +445,8 @@ class ApiClient {
} }
}) })
// TODO: handle case where no notes have yet been created, i.e. `notes` from response is `null`
const notes = await this.handleResponse<NoteMetadataResponse[]>(response, { const notes = await this.handleResponse<NoteMetadataResponse[]>(response, {
useBearerAuth: false useBearerAuth: false
}) })

View File

@ -14,7 +14,8 @@ export const MIN_PASSWORD_ENTROPY = 60.0
export const TITLE_MAX_LENGTH = 150 export const TITLE_MAX_LENGTH = 150
export const UUID_REGEX = new RegExp( export const UUID_REGEX = new RegExp(
"/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i" "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
"i"
) )
// Underestimation compared to the backend to prevent mismatches when registering // Underestimation compared to the backend to prevent mismatches when registering