Compare commits

..

No commits in common. "9805d4720ec92495ae5f0bb4348e6ef580e5b5bb" and "fceae665cccef74348e2b9b1fabbbd8b0e187dcd" have entirely different histories.

6 changed files with 72 additions and 169 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 FRONTEND_URL: http://localhost:3000
networks: networks:
notatest: notatest:

View File

@ -25,10 +25,6 @@ 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 (
@ -226,7 +222,16 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
rs.setAuthCookies(w, tokenPair, false) // Set refresh token into a httpOnly cookie
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{
@ -322,7 +327,16 @@ func (rs authResource) UpdatePassword(w http.ResponseWriter, r *http.Request) {
return return
} }
rs.setAuthCookies(w, tokenPair, false) // Set refresh token into a httpOnly cookie
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,
@ -369,7 +383,15 @@ func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) {
} }
// Clear the refresh token cookie // Clear the refresh token cookie
rs.setAuthCookies(w, nil, true) http.SetCookie(w, &http.Cookie{
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)
@ -533,7 +555,16 @@ func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request
return return
} }
rs.setAuthCookies(w, tokenPair, false) // Set refresh token into a httpOnly cookie
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{
@ -559,7 +590,15 @@ func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) {
} }
// Clear the refresh token cookie // Clear the refresh token cookie
rs.setAuthCookies(w, nil, true) http.SetCookie(w, &http.Cookie{
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")
@ -593,48 +632,6 @@ 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", "X-Csrf-Token"}, ExposedHeaders: []string{"List"},
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: #303052; --light-accent: #1e2400;
--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: #bebff7; --dark-accent: #e0e2c2;
--dark-text: rgb(238, 238, 238); --dark-text: rgb(238, 238, 238);
} }
@ -164,25 +164,16 @@
@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 */ /* Sidebar specific classes */
.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;
} }
@ -195,111 +186,60 @@
@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;
} }
.dark .sidebar-divider { /* Note editor specific classes */
@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;
} }
.dark .note-textarea { /* Settings modal specific classes */
@apply bg-[var(--dark-background)];
}
/* Settings modal */
.modal-backdrop { .modal-backdrop {
@apply fixed inset-0 z-50 flex items-center justify-center backdrop-blur-xs; /* @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 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;
} }
.dark .modal-section {
@apply border-[var(--dark-text)]/20;
}
.modal-close-button { .modal-close-button {
@apply text-[var(--light-background)]/60 hover:text-[var(--light-background)]; @apply text-[var(--light-text)]/60 hover:text-[var(--light-text)];
} }
.dark .modal-close-button { /* Loading spinner specific classes */
@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)];
} }
.dark .spinner-dot { /* Main layout classes */
@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 bg-[var(--light-background)] p-6; @apply flex-1 overflow-auto p-6;
}
.dark .main-content {
@apply bg-[var(--dark-background)];
} }
} }

View File

@ -56,7 +56,6 @@ 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)
@ -64,7 +63,6 @@ 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>(
@ -103,7 +101,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.checkAndRefreshAccessToken() await this.refreshAccessToken()
} catch (err) { } catch (err) {
console.log("refresh attempt not successful") console.log("refresh attempt not successful")
await this.handleLocalLogout() await this.handleLocalLogout()
@ -122,11 +120,7 @@ class ApiClient {
} }
private async checkAndRefreshAccessToken(): Promise<void> { private async checkAndRefreshAccessToken(): Promise<void> {
// Notably we must check whether we even have an authentication cookie present if (this.refreshInProgress) return
// 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
@ -147,10 +141,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) {
@ -172,12 +166,11 @@ 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> {
@ -192,7 +185,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()
}, },
@ -214,27 +207,6 @@ 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 () => {
@ -265,8 +237,7 @@ 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<{
@ -292,8 +263,7 @@ class ApiClient {
method: "POST", method: "POST",
headers: { headers: {
...this.getAuthHeader() ...this.getAuthHeader()
}, }
credentials: "include"
}) })
if (response.status === 204) { if (response.status === 204) {
@ -358,8 +328,7 @@ 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 })
}) })
@ -445,8 +414,6 @@ 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,8 +14,7 @@ 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}$", "/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i"
"i"
) )
// Underestimation compared to the backend to prevent mismatches when registering // Underestimation compared to the backend to prevent mismatches when registering