mirror of https://github.com/glanceapp/glance.git
Add auth
parent
0cb8a810e6
commit
6b7d68d960
@ -0,0 +1,343 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
mathrand "math/rand/v2"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const AUTH_SESSION_COOKIE_NAME = "session_token"
|
||||
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
|
||||
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||
|
||||
const AUTH_TOKEN_SECRET_LENGTH = 32
|
||||
const AUTH_USERNAME_HASH_LENGTH = 32
|
||||
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
|
||||
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
|
||||
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
|
||||
|
||||
// How long the token will be valid for
|
||||
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
|
||||
// How long the token has left before it should be regenerated
|
||||
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
|
||||
|
||||
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
|
||||
|
||||
type doWhenUnauthorized int
|
||||
|
||||
const (
|
||||
redirectToLogin doWhenUnauthorized = iota
|
||||
showUnauthorizedJSON
|
||||
)
|
||||
|
||||
type failedAuthAttempt struct {
|
||||
attempts int
|
||||
first time.Time
|
||||
}
|
||||
|
||||
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
|
||||
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
usernameHash, err := computeUsernameHash(username, secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
|
||||
copy(data, usernameHash)
|
||||
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
|
||||
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
|
||||
|
||||
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
|
||||
h.Write(data)
|
||||
|
||||
signature := h.Sum(nil)
|
||||
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
|
||||
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
|
||||
|
||||
return encodedToken, nil
|
||||
}
|
||||
|
||||
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
|
||||
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
|
||||
h.Write([]byte(username))
|
||||
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
|
||||
tokenBytes, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
|
||||
return nil, false, fmt.Errorf("token length is invalid")
|
||||
}
|
||||
|
||||
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
|
||||
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
|
||||
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
|
||||
|
||||
h := hmac.New(sha256.New, secretBytes[0:32])
|
||||
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
|
||||
expectedSignatureBytes := h.Sum(nil)
|
||||
|
||||
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
|
||||
return nil, false, fmt.Errorf("signature does not match")
|
||||
}
|
||||
|
||||
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
|
||||
if now.Unix() > expiresTimestamp {
|
||||
return nil, false, fmt.Errorf("token has expired")
|
||||
}
|
||||
|
||||
return usernameHashBytes,
|
||||
// True if the token should be regenerated
|
||||
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
|
||||
nil
|
||||
}
|
||||
|
||||
func makeAuthSecretKey(length int) (string, error) {
|
||||
key := make([]byte, length)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
|
||||
|
||||
ip := a.addressOfRequest(r)
|
||||
|
||||
a.authAttemptsMu.Lock()
|
||||
exceededRateLimit, retryAfter := func() (bool, int) {
|
||||
attempt, exists := a.failedAuthAttempts[ip]
|
||||
if !exists {
|
||||
a.failedAuthAttempts[ip] = &failedAuthAttempt{
|
||||
attempts: 1,
|
||||
first: time.Now(),
|
||||
}
|
||||
|
||||
return false, 0
|
||||
}
|
||||
|
||||
elapsed := time.Since(attempt.first)
|
||||
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
|
||||
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
|
||||
}
|
||||
|
||||
attempt.attempts++
|
||||
return false, 0
|
||||
}()
|
||||
|
||||
if exceededRateLimit {
|
||||
a.authAttemptsMu.Unlock()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
} else {
|
||||
// Clean up old failed attempts
|
||||
for ipOfAttempt := range a.failedAuthAttempts {
|
||||
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
|
||||
delete(a.failedAuthAttempts, ipOfAttempt)
|
||||
}
|
||||
}
|
||||
a.authAttemptsMu.Unlock()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var creds struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &creds)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logAuthFailure := func() {
|
||||
log.Printf(
|
||||
"Failed login attempt for user '%s' from %s",
|
||||
creds.Username, ip,
|
||||
)
|
||||
}
|
||||
|
||||
if len(creds.Username) == 0 || len(creds.Password) == 0 {
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if len(creds.Username) > 50 || len(creds.Password) > 100 {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
u, exists := a.Config.Auth.Users[creds.Username]
|
||||
if !exists {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Could not compute session token during login attempt: %v", err)
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||
|
||||
a.authAttemptsMu.Lock()
|
||||
delete(a.failedAuthAttempts, ip)
|
||||
a.authAttemptsMu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !a.RequiresAuth {
|
||||
return true
|
||||
}
|
||||
|
||||
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
|
||||
if err != nil || token.Value == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
username, exists := a.usernameHashToUsername[string(usernameHash)]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
_, exists = a.Config.Auth.Users[username]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if shouldRegenerate {
|
||||
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Could not compute session token during regeneration: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
|
||||
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
|
||||
if a.isAuthorized(w, r) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch fallback {
|
||||
case redirectToLogin:
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||
case showUnauthorizedJSON:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "Unauthorized"}`))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Maybe this should be a POST request instead?
|
||||
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
|
||||
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AUTH_SESSION_COOKIE_NAME,
|
||||
Value: token,
|
||||
Expires: expires,
|
||||
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
|
||||
Path: a.Config.Server.BaseURL + "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if a.isAuthorized(w, r) {
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
data := &templateData{
|
||||
App: a,
|
||||
}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
|
||||
var responseBytes bytes.Buffer
|
||||
err := loginPageTemplate.Execute(&responseBytes, data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(responseBytes.Bytes())
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthTokenGenerationAndVerification(t *testing.T) {
|
||||
secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate secret key: %v", err)
|
||||
}
|
||||
|
||||
secretBytes, err := base64.StdEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode secret key: %v", err)
|
||||
}
|
||||
|
||||
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||
t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
username := "admin"
|
||||
|
||||
token, err := generateSessionToken(username, secretBytes, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate session token: %v", err)
|
||||
}
|
||||
|
||||
usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to verify session token: %v", err)
|
||||
}
|
||||
|
||||
if shouldRegen {
|
||||
t.Fatal("Token should not need to be regenerated immediately after generation")
|
||||
}
|
||||
|
||||
computedUsernameHash, err := computeUsernameHash(username, secretBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to compute username hash: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
|
||||
t.Fatal("Username hash does not match the expected value")
|
||||
}
|
||||
|
||||
// Test token regeneration
|
||||
timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
|
||||
_, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
|
||||
}
|
||||
|
||||
if !shouldRegen {
|
||||
t.Fatal("Token should have been marked for regeneration")
|
||||
}
|
||||
|
||||
// Test token expiration
|
||||
_, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
|
||||
if err == nil {
|
||||
t.Fatal("Expected token verification to fail after token expiration")
|
||||
}
|
||||
|
||||
// Test tampered token
|
||||
decodedToken, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode token: %v", err)
|
||||
}
|
||||
|
||||
// If any of the bytes are off by 1, the token should be considered invalid
|
||||
for i := range len(decodedToken) {
|
||||
tampered := make([]byte, len(decodedToken))
|
||||
copy(tampered, decodedToken)
|
||||
tampered[i] += 1
|
||||
|
||||
_, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
.login-bounds {
|
||||
max-width: 500px;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
transition: border-color .2s;
|
||||
}
|
||||
|
||||
.form-input input {
|
||||
border: 0;
|
||||
background: none;
|
||||
width: 100%;
|
||||
height: 5.2rem;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.form-input-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-top: -0.1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.form-input input[type="password"] {
|
||||
letter-spacing: 0.3rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.form-input input[type="password"]::placeholder {
|
||||
letter-spacing: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.form-input:hover {
|
||||
border-color: var(--color-progress-border);
|
||||
}
|
||||
|
||||
.form-input:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
transition-duration: .7s;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-text-subdue);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-text-paragraph);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--font-size-h4);
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all .3s, margin-top 0s;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.login-button:not(:disabled) {
|
||||
box-shadow: 0 0 10px 1px var(--color-separator);
|
||||
}
|
||||
|
||||
.login-error-message:not(:empty) + .login-button {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.login-button:focus, .login-button:hover {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
border-color: var(--color-separator);
|
||||
color: var(--color-text-subdue);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-button svg {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
transition: transform .2s;
|
||||
}
|
||||
|
||||
.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg {
|
||||
transform: translateX(.5rem);
|
||||
}
|
||||
|
||||
.animate-entrance {
|
||||
animation: fieldReveal 0.7s backwards;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.animate-entrance:nth-child(1) { animation-delay: .1s; }
|
||||
.animate-entrance:nth-child(2) { animation-delay: .2s; }
|
||||
.animate-entrance:nth-child(4) { animation-delay: .3s; }
|
||||
|
||||
@keyframes fieldReveal {
|
||||
from {
|
||||
opacity: 0.0001;
|
||||
transform: translateY(4rem);
|
||||
}
|
||||
}
|
||||
|
||||
.login-error-message {
|
||||
color: var(--color-negative);
|
||||
font-size: var(--font-size-base);
|
||||
padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px);
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes errorMessageEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.login-error-message:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-error-message::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-negative);
|
||||
opacity: 0.05;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
animation-delay: .4s;
|
||||
animation-duration: 1s;
|
||||
}
|
||||
|
||||
.toggle-password-visibility {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
import { find } from "./templating.js";
|
||||
|
||||
const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate";
|
||||
|
||||
const showPasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>`;
|
||||
|
||||
const hidePasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>`;
|
||||
|
||||
const container = find("#login-container");
|
||||
const usernameInput = find("#username");
|
||||
const passwordInput = find("#password");
|
||||
const errorMessage = find("#error-message");
|
||||
const loginButton = find("#login-button");
|
||||
const toggleVisibilityButton = find("#toggle-password-visibility");
|
||||
|
||||
const state = {
|
||||
lastUsername: "",
|
||||
lastPassword: "",
|
||||
isLoading: false,
|
||||
isRateLimited: false
|
||||
};
|
||||
|
||||
const lang = {
|
||||
showPassword: "Show password",
|
||||
hidePassword: "Hide password",
|
||||
incorrectCredentials: "Incorrect username or password",
|
||||
rateLimited: "Too many login attempts, try again in a few minutes",
|
||||
unknownError: "An error occurred, please try again",
|
||||
};
|
||||
|
||||
container.clearStyles("display");
|
||||
setTimeout(() => usernameInput.focus(), 200);
|
||||
|
||||
toggleVisibilityButton
|
||||
.html(showPasswordSVG)
|
||||
.attr("title", lang.showPassword)
|
||||
.on("click", function() {
|
||||
if (passwordInput.type === "password") {
|
||||
passwordInput.type = "text";
|
||||
toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword);
|
||||
return;
|
||||
}
|
||||
|
||||
passwordInput.type = "password";
|
||||
toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword);
|
||||
});
|
||||
|
||||
function enableLoginButtonIfCriteriaMet() {
|
||||
const usernameValue = usernameInput.value.trim();
|
||||
const passwordValue = passwordInput.value.trim();
|
||||
|
||||
const usernameValid = usernameValue.length >= 3;
|
||||
const passwordValid = passwordValue.length >= 6;
|
||||
|
||||
const isUsingLastCredentials =
|
||||
usernameValue === state.lastUsername
|
||||
&& passwordValue === state.lastPassword;
|
||||
|
||||
loginButton.disabled = !(
|
||||
usernameValid
|
||||
&& passwordValid
|
||||
&& !isUsingLastCredentials
|
||||
&& !state.isLoading
|
||||
&& !state.isRateLimited
|
||||
);
|
||||
}
|
||||
|
||||
usernameInput.on("input", enableLoginButtonIfCriteriaMet);
|
||||
passwordInput.on("input", enableLoginButtonIfCriteriaMet);
|
||||
|
||||
async function handleLoginAttempt() {
|
||||
state.lastUsername = usernameInput.value;
|
||||
state.lastPassword = passwordInput.value;
|
||||
errorMessage.text("");
|
||||
|
||||
loginButton.disable();
|
||||
state.isLoading = true;
|
||||
|
||||
const response = await fetch(AUTH_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: usernameInput.value,
|
||||
password: passwordInput.value
|
||||
}),
|
||||
});
|
||||
|
||||
state.isLoading = false;
|
||||
if (response.status === 200) {
|
||||
container.animate({
|
||||
keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }],
|
||||
options: { duration: 300, easing: "ease", fill: "forwards" }}
|
||||
);
|
||||
|
||||
find("footer")?.animate({
|
||||
keyframes: [{ offset: 1, opacity: 0 }],
|
||||
options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 }
|
||||
});
|
||||
|
||||
setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300);
|
||||
} else if (response.status === 401) {
|
||||
errorMessage.text(lang.incorrectCredentials);
|
||||
passwordInput.focus();
|
||||
} else if (response.status === 429) {
|
||||
errorMessage.text(lang.rateLimited);
|
||||
state.isRateLimited = true;
|
||||
const retryAfter = response.headers.get("Retry-After") || 30;
|
||||
setTimeout(() => {
|
||||
state.lastUsername = "";
|
||||
state.lastPassword = "";
|
||||
state.isRateLimited = false;
|
||||
|
||||
enableLoginButtonIfCriteriaMet();
|
||||
}, retryAfter * 1000);
|
||||
} else {
|
||||
errorMessage.text(lang.unknownError);
|
||||
passwordInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
loginButton.disable().on("click", handleLoginAttempt);
|
||||
@ -0,0 +1,11 @@
|
||||
{{ if not .App.Config.Branding.HideFooter }}
|
||||
<footer class="footer flex items-center flex-column">
|
||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||
<div>
|
||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
{{ .App.Config.Branding.CustomFooter }}
|
||||
{{ end }}
|
||||
</footer>
|
||||
{{ end }}
|
||||
@ -0,0 +1,53 @@
|
||||
{{- template "document.html" . }}
|
||||
|
||||
{{- define "document-title" }}Login{{ end }}
|
||||
|
||||
{{- define "document-head-before" }}
|
||||
<link rel="preload" href='{{ .App.StaticAssetPath "js/templating.js" }}' as="script"/>
|
||||
<link rel="prefetch" href='{{ .App.StaticAssetPath "js/page.js" }}'/>
|
||||
{{- end }}
|
||||
|
||||
{{- define "document-head-after" }}
|
||||
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/login.css" }}'>
|
||||
<script type="module" src='{{ .App.StaticAssetPath "js/login.js" }}'></script>
|
||||
{{- end }}
|
||||
|
||||
{{- define "document-body" }}
|
||||
<div class="flex flex-column body-content">
|
||||
<div class="flex grow items-center justify-center" style="padding-bottom: 5rem">
|
||||
<h1 class="visually-hidden">Login</h1>
|
||||
<main id="login-container" class="grow login-bounds" style="display: none;">
|
||||
<div class="animate-entrance">
|
||||
<label class="form-label widget-header" for="username">Username</label>
|
||||
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
|
||||
</svg>
|
||||
<input type="text" id="username" class="input" placeholder="Enter your username" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="animate-entrance">
|
||||
<label class="form-label widget-header margin-top-20" for="password">Password</label>
|
||||
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<input type="password" id="password" class="input" placeholder="********" autocomplete="off">
|
||||
<button class="toggle-password-visibility" id="toggle-password-visibility" tabindex="-1"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-error-message" id="error-message"></div>
|
||||
|
||||
<button class="login-button animate-entrance" id="login-button">
|
||||
<div>LOGIN</div>
|
||||
<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
{{ template "footer.html" . }}
|
||||
</div>
|
||||
{{- end }}
|
||||
Loading…
Reference in New Issue