Compare commits
7 Commits
fceae665cc
...
9805d4720e
Author | SHA1 | Date | |
---|---|---|---|
9805d4720e | |||
14a87a2578 | |||
eeed3dc5d5 | |||
90ef589197 | |||
2e188c26f3 | |||
eb3c3b7a24 | |||
bf3e40eae0 |
@ -40,7 +40,7 @@ services:
|
||||
LOG_LEVEL: debug
|
||||
APP_ENV: development
|
||||
DOMAIN: localhost
|
||||
FRONTEND_URL: http://localhost:3000
|
||||
FRONTEND_URL: http://localhost
|
||||
|
||||
networks:
|
||||
notatest:
|
||||
|
@ -25,6 +25,10 @@ import (
|
||||
const (
|
||||
accessTokenDuration = 15 * time.Minute
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
|
||||
authCookieName = "notatest.refresh_token"
|
||||
viewCookieName = "notatest.expires_at"
|
||||
authCookiePath = "/api/auth/cookie"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -222,16 +226,7 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
rs.setAuthCookies(w, tokenPair, false)
|
||||
|
||||
// Build response
|
||||
response := map[string]any{
|
||||
@ -327,16 +322,7 @@ func (rs authResource) UpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
rs.setAuthCookies(w, tokenPair, false)
|
||||
|
||||
response := map[string]any{
|
||||
"access_token": tokenPair.AccessToken,
|
||||
@ -383,15 +369,7 @@ func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Clear the refresh token cookie
|
||||
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,
|
||||
})
|
||||
rs.setAuthCookies(w, nil, true)
|
||||
|
||||
if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
rs.setAuthCookies(w, tokenPair, false)
|
||||
|
||||
// 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{
|
||||
@ -590,15 +559,7 @@ func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Clear the refresh token cookie
|
||||
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,
|
||||
})
|
||||
rs.setAuthCookies(w, nil, true)
|
||||
|
||||
if err := rs.Tokens.RevokeAllUserRefreshTokens(r.Context(), userID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to logout")
|
||||
@ -632,6 +593,48 @@ 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 {
|
||||
|
@ -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"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Csrf-Token"},
|
||||
ExposedHeaders: []string{"List", "X-Csrf-Token"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
@ -3,12 +3,12 @@
|
||||
:root {
|
||||
--light-background: #f5f5f5;
|
||||
--light-foreground: #e0e0e0;
|
||||
--light-accent: #1e2400;
|
||||
--light-accent: #303052;
|
||||
--light-text: rgb(34, 40, 49);
|
||||
|
||||
--dark-background: #181818;
|
||||
--dark-foreground: #222222;
|
||||
--dark-accent: #e0e2c2;
|
||||
--dark-accent: #bebff7;
|
||||
--dark-text: rgb(238, 238, 238);
|
||||
}
|
||||
|
||||
@ -164,16 +164,25 @@
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
/* Sidebar specific classes */
|
||||
/* Sidebar */
|
||||
.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;
|
||||
}
|
||||
@ -186,60 +195,111 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
/* Note editor specific classes */
|
||||
.dark .sidebar-divider {
|
||||
@apply divide-[var(--dark-text)]/20;
|
||||
}
|
||||
|
||||
/* Note editor */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Settings modal specific classes */
|
||||
.dark .note-textarea {
|
||||
@apply bg-[var(--dark-background)];
|
||||
}
|
||||
|
||||
/* Settings modal */
|
||||
.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 bg-black;
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center backdrop-blur-xs;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
@apply text-[var(--light-text)]/60 hover:text-[var(--light-text)];
|
||||
.dark .modal-section {
|
||||
@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 {
|
||||
@apply bg-[var(--light-accent)];
|
||||
}
|
||||
|
||||
/* Main layout classes */
|
||||
.dark .spinner-dot {
|
||||
@apply bg-[var(--dark-accent)];
|
||||
}
|
||||
|
||||
/* Main layout */
|
||||
.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 p-6;
|
||||
@apply flex-1 overflow-auto bg-[var(--light-background)] p-6;
|
||||
}
|
||||
|
||||
.dark .main-content {
|
||||
@apply bg-[var(--dark-background)];
|
||||
}
|
||||
}
|
||||
|
@ -56,6 +56,7 @@ 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)
|
||||
@ -63,6 +64,7 @@ class ApiClient {
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl
|
||||
this.viewCookieName = "notatest.expires_at"
|
||||
}
|
||||
|
||||
private async handleRequest<T>(
|
||||
@ -101,7 +103,7 @@ class ApiClient {
|
||||
// but it's still good to have as a fallback
|
||||
try {
|
||||
console.log("unexpected 401 caught, attempting refresh")
|
||||
await this.refreshAccessToken()
|
||||
await this.checkAndRefreshAccessToken()
|
||||
} catch (err) {
|
||||
console.log("refresh attempt not successful")
|
||||
await this.handleLocalLogout()
|
||||
@ -120,7 +122,11 @@ class ApiClient {
|
||||
}
|
||||
|
||||
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 needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF
|
||||
@ -141,10 +147,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) {
|
||||
@ -166,11 +172,12 @@ 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> {
|
||||
@ -185,7 +192,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()
|
||||
},
|
||||
@ -207,6 +214,27 @@ 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 () => {
|
||||
@ -237,7 +265,8 @@ class ApiClient {
|
||||
const response = await fetch(`${this.baseUrl}/auth/login?${params}`, {
|
||||
method: "POST",
|
||||
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<{
|
||||
@ -263,7 +292,8 @@ class ApiClient {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.getAuthHeader()
|
||||
}
|
||||
},
|
||||
credentials: "include"
|
||||
})
|
||||
|
||||
if (response.status === 204) {
|
||||
@ -328,7 +358,8 @@ class ApiClient {
|
||||
const response = await fetch(`${this.baseUrl}/auth/owner`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
credentials: "include"
|
||||
},
|
||||
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, {
|
||||
useBearerAuth: false
|
||||
})
|
||||
|
@ -14,7 +14,8 @@ 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
|
||||
|
Loading…
x
Reference in New Issue
Block a user