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
APP_ENV: development
DOMAIN: localhost
FRONTEND_URL: http://localhost
FRONTEND_URL: http://localhost:3000
networks:
notatest:

View File

@ -25,10 +25,6 @@ import (
const (
accessTokenDuration = 15 * time.Minute
refreshTokenDuration = 7 * 24 * time.Hour
authCookieName = "notatest.refresh_token"
viewCookieName = "notatest.expires_at"
authCookiePath = "/api/auth/cookie"
)
var (
@ -226,7 +222,16 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) {
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
response := map[string]any{
@ -322,7 +327,16 @@ func (rs authResource) UpdatePassword(w http.ResponseWriter, r *http.Request) {
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{
"access_token": tokenPair.AccessToken,
@ -369,7 +383,15 @@ func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) {
}
// 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 {
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
}
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)
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
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 {
respondError(w, http.StatusInternalServerError, "Failed to logout")
@ -593,48 +632,6 @@ func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request)
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
// exists in the database.
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{
AllowedOrigins: config.allowedOrigins(),
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Csrf-Token"},
ExposedHeaders: []string{"List", "X-Csrf-Token"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"List"},
AllowCredentials: true,
MaxAge: 300,
}))

View File

@ -3,12 +3,12 @@
:root {
--light-background: #f5f5f5;
--light-foreground: #e0e0e0;
--light-accent: #303052;
--light-accent: #1e2400;
--light-text: rgb(34, 40, 49);
--dark-background: #181818;
--dark-foreground: #222222;
--dark-accent: #bebff7;
--dark-accent: #e0e2c2;
--dark-text: rgb(238, 238, 238);
}
@ -164,25 +164,16 @@
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
/* Sidebar */
/* Sidebar specific classes */
.sidebar {
@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-footer {
@apply border-[var(--light-text)]/20 p-4;
}
.dark .sidebar-header,
.dark .sidebar-footer {
@apply border-[var(--dark-text)]/20;
}
.sidebar-header {
@apply border-b;
}
@ -195,111 +186,60 @@
@apply cursor-pointer p-3 transition-colors hover:bg-[var(--light-background)];
}
.dark .sidebar-item {
@apply hover:bg-[var(--dark-background)];
}
.sidebar-item-active {
@apply bg-[var(--light-background)];
}
.dark .sidebar-item-active {
@apply bg-[var(--dark-background)];
}
.sidebar-item-text {
@apply text-xs text-[var(--light-text)]/60;
}
.dark .sidebar-item-text {
@apply text-[var(--dark-text)]/60;
}
.sidebar-divider {
@apply divide-y divide-[var(--light-text)]/20;
}
.dark .sidebar-divider {
@apply divide-[var(--dark-text)]/20;
}
/* Note editor */
/* Note editor specific classes */
.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)];
}
.dark .note-title-input {
@apply border-[var(--dark-text)]/20 focus:border-[var(--dark-accent)];
}
.note-char-count {
@apply mt-1 text-xs text-[var(--light-text)]/60;
}
.dark .note-char-count {
@apply text-[var(--dark-text)]/60;
}
.note-textarea {
@apply h-full min-h-[300px] w-full resize-none bg-[var(--light-background)] p-4 font-mono;
}
.dark .note-textarea {
@apply bg-[var(--dark-background)];
}
/* Settings modal */
/* Settings modal specific classes */
.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 {
@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 {
@apply border-b border-[var(--light-text)]/20 p-4;
}
.dark .modal-section {
@apply border-[var(--dark-text)]/20;
}
.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 {
@apply text-[var(--dark-background)]/60 hover:text-[var(--dark-background)];
}
/* Loading spinner */
/* Loading spinner specific classes */
.spinner-dot {
@apply bg-[var(--light-accent)];
}
.dark .spinner-dot {
@apply bg-[var(--dark-accent)];
}
/* Main layout */
/* Main layout classes */
.main-header {
@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 {
@apply flex-1 overflow-auto bg-[var(--light-background)] p-6;
}
.dark .main-content {
@apply bg-[var(--dark-background)];
@apply flex-1 overflow-auto p-6;
}
}

View File

@ -56,7 +56,6 @@ export const isPending: Writable<boolean> = writable(false)
export const cError: Writable<string | null> = writable(null)
class ApiClient {
private viewCookieName: string
private baseUrl: string
private lastAtUpdate = new Date(0)
private lastCsrfUpdate = new Date(0)
@ -64,7 +63,6 @@ class ApiClient {
constructor(baseUrl: string) {
this.baseUrl = baseUrl
this.viewCookieName = "notatest.expires_at"
}
private async handleRequest<T>(
@ -103,7 +101,7 @@ class ApiClient {
// but it's still good to have as a fallback
try {
console.log("unexpected 401 caught, attempting refresh")
await this.checkAndRefreshAccessToken()
await this.refreshAccessToken()
} catch (err) {
console.log("refresh attempt not successful")
await this.handleLocalLogout()
@ -122,11 +120,7 @@ class ApiClient {
}
private async checkAndRefreshAccessToken(): Promise<void> {
// 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
}
if (this.refreshInProgress) return
const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime()
const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF
@ -147,10 +141,10 @@ class ApiClient {
async () => {
const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, {
method: "POST",
credentials: "include",
headers: {
...(await this.getCsrfHeader())
},
credentials: "include"
}
})
if (!response.ok) {
@ -172,12 +166,11 @@ class ApiClient {
const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF
if (!token || needsRefresh) {
console.log("refreshing csrf token")
await this.refreshCsrfToken()
token = get(csrfToken)
}
return { "X-Csrf-Token": token || "" }
return { "X-CSRF-Token": token || "" }
}
private async refreshCsrfToken(): Promise<void> {
@ -192,7 +185,7 @@ class ApiClient {
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)
this.lastCsrfUpdate = new Date()
},
@ -214,27 +207,6 @@ class ApiClient {
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> {
return this.handleRequest(
async () => {
@ -265,8 +237,7 @@ class ApiClient {
const response = await fetch(`${this.baseUrl}/auth/login?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include"
body: JSON.stringify({ username, password })
})
const { access_token: token, user } = await this.handleResponse<{
@ -292,8 +263,7 @@ class ApiClient {
method: "POST",
headers: {
...this.getAuthHeader()
},
credentials: "include"
}
})
if (response.status === 204) {
@ -358,8 +328,7 @@ class ApiClient {
const response = await fetch(`${this.baseUrl}/auth/owner`, {
method: "DELETE",
headers: {
...this.getAuthHeader(),
credentials: "include"
...this.getAuthHeader()
},
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, {
useBearerAuth: false
})

View File

@ -14,8 +14,7 @@ export const MIN_PASSWORD_ENTROPY = 60.0
export const TITLE_MAX_LENGTH = 150
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