Compare commits

...

2 Commits

Author SHA1 Message Date
ae
8e8f5b8faf
feat: client-side view cookie reading/deletion 2025-04-21 12:28:40 +03:00
ae
d7900e8078
fix: less restrictive view cookie path 2025-04-21 12:15:23 +03:00
4 changed files with 98 additions and 44 deletions

View File

@ -29,6 +29,7 @@ const (
authCookieName = "notatest.refresh_token" authCookieName = "notatest.refresh_token"
viewCookieName = "notatest.expires_at" viewCookieName = "notatest.expires_at"
authCookiePath = "/api/auth/cookie" authCookiePath = "/api/auth/cookie"
viewCookiePath = "/"
) )
var ( var (
@ -594,25 +595,29 @@ func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request)
} }
func (rs authResource) setAuthCookies(w http.ResponseWriter, tokenPair *tokenPair, clearCookies bool) { 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) log.Debug().Msgf("Setting authentication cookies (clearCookies: %t)", clearCookies)
var maxAge int var maxAge int
var value string var expirationTime time.Time
var rtValue string
if clearCookies { if clearCookies {
expirationTime = time.Now()
maxAge = 0 // Expires immediately maxAge = 0 // Expires immediately
value = "" rtValue = ""
} else { } else {
expirationTime = time.Now().Add(refreshTokenDuration)
maxAge = int(refreshTokenDuration.Seconds()) maxAge = int(refreshTokenDuration.Seconds())
value = tokenPair.RefreshToken rtValue = tokenPair.RefreshToken
} }
expirationValue := strconv.FormatInt(expirationTime.Unix(), 10)
log.Debug().Msgf("AC: {path='%s', maxAge='%d'}, VC: {path='%s', maxAge='%d'}", authCookiePath, maxAge, viewCookiePath, maxAge)
// The actual auth cookie is httpOnly, i.e. not viewable by the client // The actual auth cookie is httpOnly, i.e. not viewable by the client
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: authCookieName, Name: authCookieName,
Value: value, Value: rtValue,
Domain: rs.Config.Domain, Domain: rs.Config.Domain,
Path: authCookiePath, Path: authCookiePath,
MaxAge: maxAge, MaxAge: maxAge,
@ -622,12 +627,13 @@ func (rs authResource) setAuthCookies(w http.ResponseWriter, tokenPair *tokenPai
}) })
// The information cookie can be used by the client to check how long it'll take until the // 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) // actual auth cookie expires (notably `HttpOnly: false` and `Path: "/"` must be set for
// the cookie to be readable from our client-side implementation)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: viewCookieName, Name: viewCookieName,
Value: expirationUnix, Value: expirationValue,
Domain: rs.Config.Domain, Domain: rs.Config.Domain,
Path: authCookiePath, Path: viewCookiePath,
MaxAge: maxAge, MaxAge: maxAge,
HttpOnly: false, HttpOnly: false,
Secure: rs.Config.IsProd, Secure: rs.Config.IsProd,

View File

@ -20,15 +20,8 @@ type SvcConfig struct {
} }
func (sc *SvcConfig) allowedOrigins() []string { func (sc *SvcConfig) allowedOrigins() []string {
var allowed []string allowed := []string{sc.FrontendURL}
if sc.IsProd {
allowed = []string{sc.FrontendURL}
} else {
allowed = []string{"http://localhost:5173"}
}
log.Debug().Msgf("CORS allowedOrigins: %v", allowed) log.Debug().Msgf("CORS allowedOrigins: %v", allowed)
return allowed return allowed
} }

View File

@ -1,5 +1,15 @@
import { get, writable, type Writable } from "svelte/store" import { get, writable, type Writable } from "svelte/store"
import { API_BASE_ADDR, AT_EXP_MS, CSRF_EXP_MS, REFRESH_BUF, UUID_REGEX } from "./const" import {
API_BASE_ADDR,
AT_EXP_MS,
COOKIE_SAME_SITE,
COOKIE_SECURE,
CSRF_EXP_MS,
REFRESH_BUF,
UUID_REGEX,
VIEW_COOKIE_DOMAIN,
VIEW_COOKIE_PATH
} from "./const"
import { goto } from "$app/navigation" import { goto } from "$app/navigation"
import { usersPagination } from "./pages" import { usersPagination } from "./pages"
@ -84,7 +94,7 @@ interface ApiFullVersionResponse {
created_at: string created_at: string
} }
// Some of these could just be local variables as they're not used globally // Some of these could just be local variables as not all of them are being used globally
export const currentUser: Writable<User | null> = writable(null) export const currentUser: Writable<User | null> = writable(null)
export const currentFullNote: Writable<FullNote | null> = writable(null) export const currentFullNote: Writable<FullNote | null> = writable(null)
export const availableNotes: Writable<NoteMetadata[] | null> = writable(null) export const availableNotes: Writable<NoteMetadata[] | null> = writable(null)
@ -124,7 +134,13 @@ class ApiClient {
// will be automatically included to corresponding requests by browser and CSRF rotation is // will be automatically included to corresponding requests by browser and CSRF rotation is
// handled by the `refreshAccessToken` method // handled by the `refreshAccessToken` method
if (options.useBearerAuth) { if (options.useBearerAuth) {
try {
await this.checkAndRefreshAccessToken() await this.checkAndRefreshAccessToken()
} catch (err) {
console.log("refresh attempt not successful")
await this.handleLocalLogout()
throw new Error("Session expired, please authenticate again.")
}
} }
return await fn() return await fn()
} catch (err) { } catch (err) {
@ -167,8 +183,10 @@ class ApiClient {
private async checkAndRefreshAccessToken(): Promise<void> { private async checkAndRefreshAccessToken(): Promise<void> {
// Notably we must check whether we even have an authentication cookie present // Notably we must check whether we even have an authentication cookie present
// as that's obviously required for completing the token rotation procedure // as that's obviously required for completing the token rotation procedure
if (this.refreshInProgress || !this.isAuthCookieValid) { if (this.refreshInProgress) {
return return
} else if (!this.isAuthCookieValid()) {
throw new Error("Session expired, please authenticate again.")
} }
const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime() const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime()
@ -182,6 +200,8 @@ class ApiClient {
this.refreshInProgress = true this.refreshInProgress = true
await this.refreshAccessToken() await this.refreshAccessToken()
this.refreshInProgress = false this.refreshInProgress = false
} else {
console.log("no need to rotate tokens")
} }
} }
@ -246,9 +266,13 @@ class ApiClient {
private async handleLocalLogout(): Promise<void> { private async handleLocalLogout(): Promise<void> {
this.lastAtUpdate = new Date(0) this.lastAtUpdate = new Date(0)
this.lastCsrfUpdate = new Date(0) this.lastCsrfUpdate = new Date(0)
currentUser.set(null) currentUser.set(null)
accessToken.set(null) accessToken.set(null)
csrfToken.set(null) csrfToken.set(null)
this.deleteViewCookie()
await goto("/login") await goto("/login")
} }
@ -259,23 +283,47 @@ class ApiClient {
private isAuthCookieValid(): boolean { private isAuthCookieValid(): boolean {
const now = new Date() const now = new Date()
const cookies = document.cookie.split(";") const viewCookie = this.getCookieValue(this.viewCookieName)
for (let cookie of cookies) { if (!viewCookie) {
const [name, value] = cookie.trim().split("=") console.log("view cookie not found")
return false
}
if (name === this.viewCookieName) { const value = viewCookie.split("=")[1]
const expirationTimestamp = parseInt(value, 10) const expirationTimestamp = parseInt(value, 10)
if (!isNaN(expirationTimestamp)) {
if (isNaN(expirationTimestamp)) {
console.log(`invalid expiration timestamp: ${value}`)
return false
}
const expirationDate = new Date(expirationTimestamp * 1000) const expirationDate = new Date(expirationTimestamp * 1000)
console.log(`auth cookie expirationDate: ${expirationDate}`) console.log(`auth cookie expiration: ${expirationDate}`)
return now < expirationDate return now < expirationDate
} }
}
private deleteViewCookie() {
const viewCookie = this.getCookieValue(this.viewCookieName)
if (!viewCookie) {
return
} }
// Fallback when the cookie can't be found // Overwrite the view cookie with details that match with the real cookie
return false document.cookie = `${this.viewCookieName}=;path=${VIEW_COOKIE_PATH};domain=${VIEW_COOKIE_DOMAIN};expires=Thu, 01 Jan 1970 00:00:00 GMT;${COOKIE_SECURE ? "secure;" : ""}sameSite=${COOKIE_SAME_SITE}`
}
private getCookieValue(cookieName: string): string | null {
const cookieString = document.cookie
const cookies = cookieString.split(";").map((cookie) => cookie.trim())
const cookieValue = cookies.find((cookie) => cookie.startsWith(`${cookieName}=`))
if (!cookieValue) {
return null
}
return cookieValue
} }
private deserializeUser(apiResponse: ApiUserResponse): User { private deserializeUser(apiResponse: ApiUserResponse): User {
@ -430,7 +478,9 @@ class ApiClient {
}) })
const data = await this.handleResponse<ApiUserResponse>(response, { useBearerAuth: false }) const data = await this.handleResponse<ApiUserResponse>(response, { useBearerAuth: false })
const user = this.deserializeUser(data) const user = this.deserializeUser(data)
console.log(user) console.log(user)
currentUser.set(user) currentUser.set(user)
}, },
{ useBearerAuth: true } { useBearerAuth: true }

View File

@ -2,14 +2,21 @@
// will automatically be proxied to the correct destination // will automatically be proxied to the correct destination
export const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api" export const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api"
// Probably shouldn't be hardcoded, but instead read from .env // Lifetimes of *in-memory* authentication tokens
export const AT_EXP_MS = 15 * 60 * 1000 // 15 min. export const AT_EXP_MS = 15 * 60 * 1000 // 15 min.
export const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h. export const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h.
export const REFRESH_BUF = 30 * 1000 // 30 s. export const REFRESH_BUF = 30 * 1000 // 30 s.
// User registration password restrictions
export const MIN_PASSWORD_LENGTH = 12 export const MIN_PASSWORD_LENGTH = 12
export const MAX_PASSWORD_LENGTH = 72 export const MAX_PASSWORD_LENGTH = 72
export const MIN_PASSWORD_ENTROPY = 60.0 export const MIN_PASSWORD_ENTROPY = 60.0
export const ENTROPY_CLASSES: Array<[RegExp, number]> = [
[/[a-z]/, 24], // 26
[/[A-Z]/, 24], // 26
[/\d/, 10],
[/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40
]
export const TITLE_MAX_LENGTH = 150 export const TITLE_MAX_LENGTH = 150
@ -18,10 +25,8 @@ export const UUID_REGEX = new RegExp(
"i" "i"
) )
// Underestimation compared to the backend to prevent mismatches when registering // View cookie configuration (holds UNIX timestamp value of the actual refresh token cookie's expiration date)
export const ENTROPY_CLASSES: Array<[RegExp, number]> = [ export const VIEW_COOKIE_PATH = import.meta.env.VITE_VIEW_COOKIE_PATH || "/"
[/[a-z]/, 24], // 26 export const VIEW_COOKIE_DOMAIN = import.meta.env.VITE_VIEW_COOKIE_DOMAIN || "localhost"
[/[A-Z]/, 24], // 26 export const COOKIE_SAME_SITE = import.meta.env.VITE_COOKIE_SAME_SITE || "strict"
[/\d/, 10], export const COOKIE_SECURE = import.meta.env.PROD ? true : false
[/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40
]