Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: remember me #1661

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/config/config_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ func DefaultConfig() *Config {
Host: "localhost",
},
Session: Session{
Lifespan: "12h",
Lifespan: "12h",
EnableRememberMe: false,
Cookie: Cookie{
HttpOnly: true,
SameSite: "strict",
Expand Down
4 changes: 4 additions & 0 deletions backend/config/config_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type Session struct {
Lifespan string `yaml:"lifespan" json:"lifespan,omitempty" koanf:"lifespan" jsonschema:"default=12h"`
// `server_side` contains configuration for server-side sessions.
ServerSide ServerSide `yaml:"server_side" json:"server_side" koanf:"server_side"`
// `enable_remember_me` determines whether remember me functionality should be enable in hanko element ui
// which allows users to control the session duration whether the JWT should be store in session cookie and deleted
// when browser is closed or not.
EnableRememberMe bool `yaml:"enable_remember_me" json:"enable_remember_me,omitempty" koanf:"enable_remember_me" split_words:"true" jsonschema:"default=false"`
}

func (s *Session) Validate() error {
Expand Down
43 changes: 43 additions & 0 deletions backend/flow_api/flow/credential_usage/action_remember_me.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package credential_usage

import (
"fmt"

"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
"github.com/teamhanko/hanko/backend/flowpilot"
)

type RememberMe struct {
shared.Action
}

func (a RememberMe) GetName() flowpilot.ActionName {
return shared.ActionRememberMe
}

func (a RememberMe) GetDescription() string {
return "Show a remember me checkbox."
}

func (a RememberMe) Initialize(c flowpilot.InitializationContext) {
deps := a.GetDeps(c)

c.AddInputs(flowpilot.BooleanInput("remember_me").Required(true))

if deps.Cfg.Session.EnableRememberMe {
c.SuspendAction()
}
}

func (a RememberMe) Execute(c flowpilot.ExecutionContext) error {

if valid := c.ValidateInputData(); !valid {
return c.Error(flowpilot.ErrorFormDataInvalid)
}

if err := c.Stash().Set(shared.StashPathRememberMe, c.Input().Get(shared.StashPathRememberMe).Bool()); err != nil {
return fmt.Errorf("failed to set remember_me to stash: %w", err)
}
Mujhtech marked this conversation as resolved.
Show resolved Hide resolved

return c.Continue(shared.StateLoginInit)
}
4 changes: 3 additions & 1 deletion backend/flow_api/flow/flows.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package flow

import (
"time"

"github.com/teamhanko/hanko/backend/flow_api/flow/capabilities"
"github.com/teamhanko/hanko/backend/flow_api/flow/credential_onboarding"
"github.com/teamhanko/hanko/backend/flow_api/flow/credential_usage"
Expand All @@ -10,7 +12,6 @@ import (
"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
"github.com/teamhanko/hanko/backend/flow_api/flow/user_details"
"github.com/teamhanko/hanko/backend/flowpilot"
"time"
)

var CapabilitiesSubFlow = flowpilot.NewSubFlow(shared.FlowCapabilities).
Expand All @@ -22,6 +23,7 @@ var CredentialUsageSubFlow = flowpilot.NewSubFlow(shared.FlowCredentialUsage).
credential_usage.ContinueWithLoginIdentifier{},
credential_usage.WebauthnGenerateRequestOptions{},
credential_usage.WebauthnVerifyAssertionResponse{},
credential_usage.RememberMe{},
shared.ThirdPartyOAuth{}).
State(shared.StateLoginPasskey,
credential_usage.WebauthnVerifyAssertionResponse{},
Expand Down
1 change: 1 addition & 0 deletions backend/flow_api/flow/shared/const_action_names.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ const (
ActionWebauthnGenerateRequestOptions flowpilot.ActionName = "webauthn_generate_request_options"
ActionWebauthnVerifyAssertionResponse flowpilot.ActionName = "webauthn_verify_assertion_response"
ActionWebauthnVerifyAttestationResponse flowpilot.ActionName = "webauthn_verify_attestation_response"
ActionRememberMe flowpilot.ActionName = "remember_me"
ActionSessionDelete flowpilot.ActionName = "session_delete"
)
1 change: 1 addition & 0 deletions backend/flow_api/flow/shared/const_stash_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ const (
StashPathUserIdentification = "user_identification"
StashPathLoginOnboardingScheduled = "login_onboarding_scheduled"
StashPathLoginOnboardingCreateEmail = "login_onboarding_create_email"
StashPathRememberMe = "remember_me"
)
18 changes: 17 additions & 1 deletion backend/flow_api/flow/shared/hook_issue_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package shared
import (
"errors"
"fmt"
"net/http"

"github.com/gofrs/uuid"
auditlog "github.com/teamhanko/hanko/backend/audit_log"
"github.com/teamhanko/hanko/backend/dto"
Expand All @@ -18,6 +20,7 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error {
deps := h.GetDeps(c)

var userId uuid.UUID
var rememberMe bool
var err error
if c.Stash().Get(StashPathUserID).Exists() {
userId, err = uuid.FromString(c.Stash().Get(StashPathUserID).String())
Expand All @@ -28,6 +31,12 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error {
return errors.New("user_id not found in stash")
}

if c.Stash().Get(StashPathRememberMe).Exists() {
rememberMe = c.Stash().Get(StashPathRememberMe).Bool()
} else {
rememberMe = false
}
Mujhtech marked this conversation as resolved.
Show resolved Hide resolved

emails, err := deps.Persister.GetEmailPersisterWithConnection(deps.Tx).FindByUserId(userId)
if err != nil {
return fmt.Errorf("failed to fetch emails from db: %w", err)
Expand Down Expand Up @@ -80,12 +89,18 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error {
}
}

cookie, err := deps.SessionManager.GenerateCookie(signedSessionToken)
cookie, err := deps.SessionManager.GenerateCookie(signedSessionToken, func(c *http.Cookie) {
if rememberMe {
c.MaxAge = 0
}
})

if err != nil {
return fmt.Errorf("failed to generate auth cookie, %w", err)
}

deps.HttpContext.Response().Header().Set("X-Session-Lifetime", fmt.Sprintf("%d", cookie.MaxAge))
deps.HttpContext.Response().Header().Set("X-Remember-Me", fmt.Sprintf("%t", rememberMe))

if deps.Cfg.Session.EnableAuthTokenHeader {
deps.HttpContext.Response().Header().Set("X-Auth-Token", signedSessionToken)
Expand All @@ -111,4 +126,5 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error {
}

return nil

}
11 changes: 6 additions & 5 deletions backend/handler/webauthn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ package handler
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/go-webauthn/webauthn/protocol"
"github.com/gofrs/uuid"
"github.com/labstack/echo/v4"
Expand All @@ -13,10 +18,6 @@ import (
"github.com/teamhanko/hanko/backend/persistence/models"
"github.com/teamhanko/hanko/backend/session"
"github.com/teamhanko/hanko/backend/test"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestWebauthnSuite(t *testing.T) {
Expand Down Expand Up @@ -336,7 +337,7 @@ func (s sessionManager) GenerateJWT(_ uuid.UUID, _ *dto.EmailJwt) (string, jwt.T
return userId, nil, nil
}

func (s sessionManager) GenerateCookie(token string) (*http.Cookie, error) {
func (s sessionManager) GenerateCookie(token string, opts ...session.CookieOption) (*http.Cookie, error) {
return &http.Cookie{
Name: "hanko",
Value: token,
Expand Down
22 changes: 16 additions & 6 deletions backend/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ package session

import (
"fmt"
"net/http"
"time"

"github.com/gofrs/uuid"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/teamhanko/hanko/backend/config"
hankoJwk "github.com/teamhanko/hanko/backend/crypto/jwk"
hankoJwt "github.com/teamhanko/hanko/backend/crypto/jwt"
"github.com/teamhanko/hanko/backend/dto"
"net/http"
"time"
)

type CookieOption func(*http.Cookie)

type Manager interface {
GenerateJWT(userId uuid.UUID, userDto *dto.EmailJwt) (string, jwt.Token, error)
Verify(string) (jwt.Token, error)
GenerateCookie(token string) (*http.Cookie, error)
GenerateCookie(token string, opts ...CookieOption) (*http.Cookie, error)
DeleteCookie() (*http.Cookie, error)
}

Expand Down Expand Up @@ -132,8 +135,9 @@ func (m *manager) Verify(token string) (jwt.Token, error) {
}

// GenerateCookie creates a new session cookie for the given user
func (m *manager) GenerateCookie(token string) (*http.Cookie, error) {
return &http.Cookie{
func (m *manager) GenerateCookie(token string, opts ...CookieOption) (*http.Cookie, error) {

cookie := &http.Cookie{
Name: m.cookieConfig.Name,
Value: token,
Domain: m.cookieConfig.Domain,
Expand All @@ -142,7 +146,13 @@ func (m *manager) GenerateCookie(token string) (*http.Cookie, error) {
HttpOnly: m.cookieConfig.HttpOnly,
SameSite: m.cookieConfig.SameSite,
MaxAge: int(m.sessionLength.Seconds()),
}, nil
}

for _, opt := range opts {
opt(cookie)
}

return cookie, nil
}

// DeleteCookie returns a cookie that will expire the cookie on the frontend
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const bn: Translation = {
setUsername: "ব্যবহারকারীর নাম সেট করুন",
changePassword: "পাসওয়ার্ড পরিবর্তন করুন",
setPassword: "পাসওয়ার্ড সেট করুন",
rememberMe: "আমাকে মনে রাখুন",
revoke: "বাতিল করুন",
currentSession: "বর্তমান সেশন",
},
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const de: Translation = {
setUsername: "Benutzernamen setzen",
changePassword: "Passwort ändern",
setPassword: "Passwort setzen",
rememberMe: "Angemeldet bleiben",
revoke: "Beenden",
currentSession: "Aktuelle Sitzung",
},
Expand Down
3 changes: 2 additions & 1 deletion frontend/elements/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const en: Translation = {
lastUsed: "Last seen",
ipAddress: "IP address",
revokeSession: "Revoke session",
profileSessions: "Sessions"
profileSessions: "Sessions",
},
texts: {
enterPasscode: 'Enter the passcode that was sent to "{emailAddress}".',
Expand Down Expand Up @@ -108,6 +108,7 @@ export const en: Translation = {
setUsername: "Set username",
changePassword: "Change password",
setPassword: "Set password",
rememberMe: "Remember me",
revoke: "Revoke",
currentSession: "Current session",
},
Expand Down
3 changes: 2 additions & 1 deletion frontend/elements/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const fr: Translation = {
lastUsed: "Dernière vue",
ipAddress: "Adresse IP",
revokeSession: "Révoquer la session",
profileSessions: "Sessions"
profileSessions: "Sessions",
},
texts: {
enterPasscode:
Expand Down Expand Up @@ -112,6 +112,7 @@ export const fr: Translation = {
setUsername: "Définir le nom d'utilisateur",
changePassword: "Changer le mot de passe",
setPassword: "Définir le mot de passe",
rememberMe: "Rester connecté",
revoke: "Révoquer",
currentSession: "Session en cours",
},
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const it: Translation = {
setUsername: "Imposta nome utente",
changePassword: "Cambia password",
setPassword: "Imposta password",
rememberMe: "Ricordami",
revoke: "Revoca",
currentSession: "Sessione corrente",
},
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const ptBR: Translation = {
setUsername: "Definir nome de usuário",
changePassword: "Alterar senha",
setPassword: "Definir senha",
rememberMe: "Lembrar-me",
revoke: "Revogar",
currentSession: "Sessão atual",
},
Expand Down
3 changes: 2 additions & 1 deletion frontend/elements/src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface Translation {
lastUsed: string;
ipAddress: string;
revokeSession: string;
profileSessions: string
profileSessions: string;
};
texts: {
enterPasscode: string;
Expand Down Expand Up @@ -104,6 +104,7 @@ export interface Translation {
setPassword: string;
changeUsername: string;
setUsername: string;
rememberMe: string;
revoke: string;
currentSession: string;
};
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const zh: Translation = {
setUsername: "设置用户名",
changePassword: "更改密码",
setPassword: "设置密码",
rememberMe: "记住我",
revoke: "撤销",
currentSession: "当前会话",
},
Expand Down
22 changes: 22 additions & 0 deletions frontend/elements/src/pages/LoginInitPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import ErrorBox from "../components/error/ErrorBox";
import Headline1 from "../components/headline/Headline1";
import Link from "../components/link/Link";
import Footer from "../components/wrapper/Footer";
import Checkbox from "../components/form/Checkbox";

interface Props {
state: State<"login_init">;
Expand Down Expand Up @@ -50,6 +51,7 @@ const LoginInitPage = (props: Props) => {
const [thirdPartyError, setThirdPartyError] = useState<
HankoError | undefined
>(undefined);
const [rememberMe, setRememberMe] = useState<boolean>(false);

const onIdentifierInput = (event: Event) => {
event.preventDefault();
Expand All @@ -60,6 +62,16 @@ const LoginInitPage = (props: Props) => {
}
};

const onRememberMeChange = async (event: Event) => {
setRememberMe((prev) => !prev);

const nextState = await flowState.actions
.remember_me({ remember_me: !rememberMe })
.run();

stateHandler[nextState.name](nextState);
};

const onEmailSubmit = async (event: Event) => {
event.preventDefault();

Expand Down Expand Up @@ -223,6 +235,16 @@ const LoginInitPage = (props: Props) => {
placeholder={t("labels.emailOrUsername")}
/>
)}
{flowState.actions.remember_me?.(null) && (
<Checkbox
required={false}
type={"checkbox"}
label={t("labels.rememberMe")}
checked={rememberMe}
onChange={onRememberMeChange}
/>
)}

<Button uiAction={"email-submit"}>{t("labels.continue")}</Button>
</Form>
<Divider hidden={!showDivider}>{t("labels.or")}</Divider>
Expand Down
1 change: 1 addition & 0 deletions frontend/elements/src/pages/LoginPasswordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Link from "../components/link/Link";
import Headline1 from "../components/headline/Headline1";
import { State } from "@teamhanko/hanko-frontend-sdk/dist/lib/flow-api/State";
import { useFlowState } from "../contexts/FlowState";
import Checkbox from "../components/form/Checkbox";

type Props = {
state: State<"login_password">;
Expand Down
Loading