implement remember-me functionality

This commit is contained in:
Hayden 2023-03-22 21:36:49 -08:00
parent a275090e03
commit 3ca4ad8836
No known key found for this signature in database
GPG key ID: 17CF79474E257545
13 changed files with 145 additions and 46 deletions

View file

@ -25,7 +25,7 @@ func (a *app) SetupDemo() {
}
// First check if we've already setup a demo user and skip if so
_, err := a.services.User.Login(context.Background(), registration.Email, registration.Password)
_, err := a.services.User.Login(context.Background(), registration.Email, registration.Password, false)
if err == nil {
return
}
@ -36,7 +36,7 @@ func (a *app) SetupDemo() {
log.Fatal().Msg("Failed to setup demo")
}
token, _ := a.services.User.Login(context.Background(), registration.Email, registration.Password)
token, _ := a.services.User.Login(context.Background(), registration.Email, registration.Password, false)
self, _ := a.services.User.GetSelf(context.Background(), token.Raw)
_, err = a.services.Items.CsvImport(context.Background(), self.GroupID, strings.NewReader(csvText))

View file

@ -21,8 +21,9 @@ type (
}
LoginForm struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
StayLoggedIn bool `json:"stayLoggedIn"`
}
)
@ -34,6 +35,7 @@ type (
// @Accept application/json
// @Param username formData string false "string" example(admin@admin.com)
// @Param password formData string false "string" example(admin)
// @Param payload body LoginForm true "Login Data"
// @Produce json
// @Success 200 {object} TokenResponse
// @Router /v1/users/login [POST]
@ -50,6 +52,7 @@ func (ctrl *V1Controller) HandleAuthLogin() errchain.HandlerFunc {
loginForm.Username = r.PostFormValue("username")
loginForm.Password = r.PostFormValue("password")
loginForm.StayLoggedIn = r.PostFormValue("stayLoggedIn") == "true"
case "application/json":
err := server.Decode(r, loginForm)
if err != nil {
@ -73,7 +76,7 @@ func (ctrl *V1Controller) HandleAuthLogin() errchain.HandlerFunc {
)
}
newToken, err := ctrl.svc.User.Login(r.Context(), strings.ToLower(loginForm.Username), loginForm.Password)
newToken, err := ctrl.svc.User.Login(r.Context(), strings.ToLower(loginForm.Username), loginForm.Password, loginForm.StayLoggedIn)
if err != nil {
return validate.NewRequestError(errors.New("authentication failed"), http.StatusInternalServerError)
}

View file

@ -1575,6 +1575,15 @@ const docTemplate = `{
"description": "string",
"name": "password",
"in": "formData"
},
{
"description": "Login Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.LoginForm"
}
}
],
"responses": {
@ -2761,6 +2770,20 @@ const docTemplate = `{
}
}
},
"v1.LoginForm": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"stayLoggedIn": {
"type": "boolean"
},
"username": {
"type": "string"
}
}
},
"v1.TokenResponse": {
"type": "object",
"properties": {

View file

@ -1567,6 +1567,15 @@
"description": "string",
"name": "password",
"in": "formData"
},
{
"description": "Login Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.LoginForm"
}
}
],
"responses": {
@ -2753,6 +2762,20 @@
}
}
},
"v1.LoginForm": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"stayLoggedIn": {
"type": "boolean"
},
"username": {
"type": "string"
}
}
},
"v1.TokenResponse": {
"type": "object",
"properties": {

View file

@ -676,6 +676,15 @@ definitions:
token:
type: string
type: object
v1.LoginForm:
properties:
password:
type: string
stayLoggedIn:
type: boolean
username:
type: string
type: object
v1.TokenResponse:
properties:
attachmentToken:
@ -1642,6 +1651,12 @@ paths:
in: formData
name: password
type: string
- description: Login Data
in: body
name: payload
required: true
schema:
$ref: '#/definitions/v1.LoginForm'
produces:
- application/json
responses:

View file

@ -140,12 +140,18 @@ func (svc *UserService) UpdateSelf(ctx context.Context, ID uuid.UUID, data repo.
// ============================================================================
// User Authentication
func (svc *UserService) createSessionToken(ctx context.Context, userId uuid.UUID) (UserAuthTokenDetail, error) {
func (svc *UserService) createSessionToken(ctx context.Context, userId uuid.UUID, extendedSession bool) (UserAuthTokenDetail, error) {
attachmentToken := hasher.GenerateToken()
expiresAt := time.Now().Add(oneWeek)
if extendedSession {
expiresAt = time.Now().Add(oneWeek * 4)
}
attachmentData := repo.UserAuthTokenCreate{
UserID: userId,
TokenHash: attachmentToken.Hash,
ExpiresAt: time.Now().Add(oneWeek),
ExpiresAt: expiresAt,
}
_, err := svc.repos.AuthTokens.CreateToken(ctx, attachmentData, authroles.RoleAttachments)
@ -157,7 +163,7 @@ func (svc *UserService) createSessionToken(ctx context.Context, userId uuid.UUID
data := repo.UserAuthTokenCreate{
UserID: userId,
TokenHash: userToken.Hash,
ExpiresAt: time.Now().Add(oneWeek),
ExpiresAt: expiresAt,
}
created, err := svc.repos.AuthTokens.CreateToken(ctx, data, authroles.RoleUser)
@ -172,7 +178,7 @@ func (svc *UserService) createSessionToken(ctx context.Context, userId uuid.UUID
}, nil
}
func (svc *UserService) Login(ctx context.Context, username, password string) (UserAuthTokenDetail, error) {
func (svc *UserService) Login(ctx context.Context, username, password string, extendedSession bool) (UserAuthTokenDetail, error) {
usr, err := svc.repos.Users.GetOneEmail(ctx, username)
if err != nil {
// SECURITY: Perform hash to ensure response times are the same
@ -184,7 +190,7 @@ func (svc *UserService) Login(ctx context.Context, username, password string) (U
return UserAuthTokenDetail{}, ErrorInvalidLogin
}
return svc.createSessionToken(ctx, usr.ID)
return svc.createSessionToken(ctx, usr.ID, extendedSession)
}
func (svc *UserService) Logout(ctx context.Context, token string) error {
@ -201,7 +207,7 @@ func (svc *UserService) RenewToken(ctx context.Context, token string) (UserAuthT
return UserAuthTokenDetail{}, ErrorInvalidToken
}
return svc.createSessionToken(ctx, dbToken.ID)
return svc.createSessionToken(ctx, dbToken.ID, false)
}
// DeleteSelf deletes the user that is currently logged based of the provided UUID

View file

@ -1567,6 +1567,15 @@
"description": "string",
"name": "password",
"in": "formData"
},
{
"description": "Login Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.LoginForm"
}
}
],
"responses": {
@ -2753,6 +2762,20 @@
}
}
},
"v1.LoginForm": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"stayLoggedIn": {
"type": "boolean"
},
"username": {
"type": "string"
}
}
},
"v1.TokenResponse": {
"type": "object",
"properties": {

View file

@ -1,9 +1,9 @@
<template>
<div class="flex">
<div class="relative">
<FormTextField v-model="value" placeholder="Password" label="Password" :type="inputType"> </FormTextField>
<button
type="button"
class="inline-flex p-1 ml-1 justify-center mt-auto mb-3 tooltip"
class="inline-flex p-1 ml-1 justify-center mt-auto mb-3 tooltip absolute top-11 right-3"
data-tip="Toggle Password Show"
@click="toggle()"
>

View file

@ -36,13 +36,17 @@ export interface IAuthContext {
/**
* Logs in the user and sets the authorization context via cookies
*/
login(api: PublicApi, email: string, password: string): ReturnType<PublicApi["login"]>;
login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean): ReturnType<PublicApi["login"]>;
}
class AuthContext implements IAuthContext {
// eslint-disable-next-line no-use-before-define
private static _instance?: AuthContext;
private static readonly cookieTokenKey = "hb.auth.token";
private static readonly cookieExpiresAtKey = "hb.auth.expires_at";
private static readonly cookieAttachmentTokenKey = "hb.auth.attachment_token";
user?: UserOut;
private _token: CookieRef<string | null>;
private _expiresAt: CookieRef<string | null>;
@ -68,7 +72,11 @@ class AuthContext implements IAuthContext {
static get instance() {
if (!this._instance) {
this._instance = new AuthContext("hb.auth.token", "hb.auth.expires_at", "hb.auth.attachment_token");
this._instance = new AuthContext(
AuthContext.cookieTokenKey,
AuthContext.cookieExpiresAtKey,
AuthContext.cookieAttachmentTokenKey
);
}
return this._instance;
@ -94,29 +102,21 @@ class AuthContext implements IAuthContext {
this.user = undefined;
// Delete the cookies
// @ts-expect-error
this._token.value = undefined;
// @ts-expect-error
this._expiresAt.value = undefined;
// @ts-expect-error
this._attachmentToken.value = undefined;
this._token.value = null;
this._expiresAt.value = null;
this._attachmentToken.value = null;
navigateTo("/");
console.log("Session invalidated");
}
async login(api: PublicApi, email: string, password: string) {
const r = await api.login(email, password);
async login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean) {
const r = await api.login(email, password, stayLoggedIn);
if (!r.error) {
this._token.value = r.data.token;
this._expiresAt.value = r.data.expiresAt as string;
this._attachmentToken.value = r.data.attachmentToken;
console.log({
token: this._token.value,
expiresAt: this._expiresAt.value,
attachmentToken: this._attachmentToken.value,
});
}
return r;

View file

@ -226,11 +226,6 @@
const api = useUserApi();
async function logout() {
const { error } = await authCtx.logout(api);
if (error) {
return;
}
navigateTo("/");
await authCtx.logout(api);
}
</script>

View file

@ -1,10 +1,5 @@
import { BaseAPI, route } from "./base";
import { ApiSummary, TokenResponse, UserRegistration } from "./types/data-contracts";
export type LoginPayload = {
username: string;
password: string;
};
import { ApiSummary, LoginForm, TokenResponse, UserRegistration } from "./types/data-contracts";
export type StatusResult = {
health: boolean;
@ -18,12 +13,13 @@ export class PublicApi extends BaseAPI {
return this.http.get<ApiSummary>({ url: route("/status") });
}
public login(username: string, password: string) {
return this.http.post<LoginPayload, TokenResponse>({
public login(username: string, password: string, stayLoggedIn: boolean) {
return this.http.post<LoginForm, TokenResponse>({
url: route("/users/login"),
body: {
username,
password,
stayLoggedIn,
},
});
}

View file

@ -412,6 +412,12 @@ export interface ItemAttachmentToken {
token: string;
}
export interface LoginForm {
password: string;
stayLoggedIn: boolean;
username: string;
}
export interface TokenResponse {
attachmentToken: string;
expiresAt: Date | string;

View file

@ -40,6 +40,7 @@
const email = ref("");
const password = ref("");
const canRegister = ref(false);
const remember = ref(false);
const groupToken = computed<string>({
get() {
@ -91,7 +92,7 @@
async function login() {
loading.value = true;
const { error } = await ctx.login(api, email.value, loginPassword.value);
const { error } = await ctx.login(api, email.value, loginPassword.value, remember.value);
if (error) {
toast.error("Invalid email or password");
@ -196,8 +197,16 @@
</template>
<FormTextField v-model="email" label="Email" />
<FormPassword v-model="loginPassword" label="Password" />
<div class="card-actions justify-end mt-2">
<button type="submit" class="btn btn-primary" :class="loading ? 'loading' : ''" :disabled="loading">
<div class="max-w-[140px]">
<FormCheckbox v-model="remember" label="Remember Me" />
</div>
<div class="card-actions justify-end">
<button
type="submit"
class="btn btn-primary btn-block"
:class="loading ? 'loading' : ''"
:disabled="loading"
>
Login
</button>
</div>