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 // 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 { if err == nil {
return return
} }
@ -36,7 +36,7 @@ func (a *app) SetupDemo() {
log.Fatal().Msg("Failed to setup demo") 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) self, _ := a.services.User.GetSelf(context.Background(), token.Raw)
_, err = a.services.Items.CsvImport(context.Background(), self.GroupID, strings.NewReader(csvText)) _, err = a.services.Items.CsvImport(context.Background(), self.GroupID, strings.NewReader(csvText))

View file

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

View file

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

View file

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

View file

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

View file

@ -140,12 +140,18 @@ func (svc *UserService) UpdateSelf(ctx context.Context, ID uuid.UUID, data repo.
// ============================================================================ // ============================================================================
// User Authentication // 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() attachmentToken := hasher.GenerateToken()
expiresAt := time.Now().Add(oneWeek)
if extendedSession {
expiresAt = time.Now().Add(oneWeek * 4)
}
attachmentData := repo.UserAuthTokenCreate{ attachmentData := repo.UserAuthTokenCreate{
UserID: userId, UserID: userId,
TokenHash: attachmentToken.Hash, TokenHash: attachmentToken.Hash,
ExpiresAt: time.Now().Add(oneWeek), ExpiresAt: expiresAt,
} }
_, err := svc.repos.AuthTokens.CreateToken(ctx, attachmentData, authroles.RoleAttachments) _, 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{ data := repo.UserAuthTokenCreate{
UserID: userId, UserID: userId,
TokenHash: userToken.Hash, TokenHash: userToken.Hash,
ExpiresAt: time.Now().Add(oneWeek), ExpiresAt: expiresAt,
} }
created, err := svc.repos.AuthTokens.CreateToken(ctx, data, authroles.RoleUser) 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 }, 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) usr, err := svc.repos.Users.GetOneEmail(ctx, username)
if err != nil { if err != nil {
// SECURITY: Perform hash to ensure response times are the same // 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 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 { 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 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 // DeleteSelf deletes the user that is currently logged based of the provided UUID

View file

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

View file

@ -1,9 +1,9 @@
<template> <template>
<div class="flex"> <div class="relative">
<FormTextField v-model="value" placeholder="Password" label="Password" :type="inputType"> </FormTextField> <FormTextField v-model="value" placeholder="Password" label="Password" :type="inputType"> </FormTextField>
<button <button
type="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" data-tip="Toggle Password Show"
@click="toggle()" @click="toggle()"
> >

View file

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

View file

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

View file

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

View file

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

View file

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