Commit 1fffc41f authored by johnnyjoy's avatar johnnyjoy

feat: sliding expiration for user sessions

parent e93d695a
......@@ -38,8 +38,9 @@ message GetCurrentSessionRequest {}
message GetCurrentSessionResponse {
User user = 1;
// Current session expiration time (if available).
google.protobuf.Timestamp expires_at = 2;
// Last time the session was accessed.
// Used for sliding expiration calculation (last_accessed_time + 2 weeks).
google.protobuf.Timestamp last_accessed_at = 2;
}
message CreateSessionRequest {
......@@ -78,18 +79,15 @@ message CreateSessionRequest {
// SSO provider authentication method.
SSOCredentials sso_credentials = 2;
}
// Whether the session should never expire.
// Optional field that defaults to false for security.
bool never_expire = 3 [(google.api.field_behavior) = OPTIONAL];
}
message CreateSessionResponse {
// The authenticated user information.
User user = 1;
// Token expiration time.
google.protobuf.Timestamp expires_at = 2;
// Last time the session was accessed.
// Used for sliding expiration calculation (last_accessed_time + 2 weeks).
google.protobuf.Timestamp last_accessed_at = 2;
}
message DeleteSessionRequest {}
......@@ -481,14 +481,12 @@ message UserSession {
// The timestamp when the session was created.
google.protobuf.Timestamp create_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
// The timestamp when the session expires.
google.protobuf.Timestamp expire_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
// The timestamp when the session was last accessed.
google.protobuf.Timestamp last_accessed_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
// Used for sliding expiration calculation (last_accessed_time + 2 weeks).
google.protobuf.Timestamp last_accessed_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
// Client information associated with this session.
ClientInfo client_info = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
ClientInfo client_info = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
message ClientInfo {
// User agent string of the client.
......
......@@ -63,10 +63,11 @@ func (*GetCurrentSessionRequest) Descriptor() ([]byte, []int) {
type GetCurrentSessionResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
// Current session expiration time (if available).
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Last time the session was accessed.
// Used for sliding expiration calculation (last_accessed_time + 2 weeks).
LastAccessedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=last_accessed_at,json=lastAccessedAt,proto3" json:"last_accessed_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetCurrentSessionResponse) Reset() {
......@@ -106,9 +107,9 @@ func (x *GetCurrentSessionResponse) GetUser() *User {
return nil
}
func (x *GetCurrentSessionResponse) GetExpiresAt() *timestamppb.Timestamp {
func (x *GetCurrentSessionResponse) GetLastAccessedAt() *timestamppb.Timestamp {
if x != nil {
return x.ExpiresAt
return x.LastAccessedAt
}
return nil
}
......@@ -122,10 +123,7 @@ type CreateSessionRequest struct {
//
// *CreateSessionRequest_PasswordCredentials_
// *CreateSessionRequest_SsoCredentials
Credentials isCreateSessionRequest_Credentials `protobuf_oneof:"credentials"`
// Whether the session should never expire.
// Optional field that defaults to false for security.
NeverExpire bool `protobuf:"varint,3,opt,name=never_expire,json=neverExpire,proto3" json:"never_expire,omitempty"`
Credentials isCreateSessionRequest_Credentials `protobuf_oneof:"credentials"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
......@@ -185,13 +183,6 @@ func (x *CreateSessionRequest) GetSsoCredentials() *CreateSessionRequest_SSOCred
return nil
}
func (x *CreateSessionRequest) GetNeverExpire() bool {
if x != nil {
return x.NeverExpire
}
return false
}
type isCreateSessionRequest_Credentials interface {
isCreateSessionRequest_Credentials()
}
......@@ -214,10 +205,11 @@ type CreateSessionResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The authenticated user information.
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
// Token expiration time.
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Last time the session was accessed.
// Used for sliding expiration calculation (last_accessed_time + 2 weeks).
LastAccessedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=last_accessed_at,json=lastAccessedAt,proto3" json:"last_accessed_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateSessionResponse) Reset() {
......@@ -257,9 +249,9 @@ func (x *CreateSessionResponse) GetUser() *User {
return nil
}
func (x *CreateSessionResponse) GetExpiresAt() *timestamppb.Timestamp {
func (x *CreateSessionResponse) GetLastAccessedAt() *timestamppb.Timestamp {
if x != nil {
return x.ExpiresAt
return x.LastAccessedAt
}
return nil
}
......@@ -429,15 +421,13 @@ var File_api_v1_auth_service_proto protoreflect.FileDescriptor
const file_api_v1_auth_service_proto_rawDesc = "" +
"\n" +
"\x19api/v1/auth_service.proto\x12\fmemos.api.v1\x1a\x19api/v1/user_service.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x1a\n" +
"\x18GetCurrentSessionRequest\"~\n" +
"\x18GetCurrentSessionRequest\"\x89\x01\n" +
"\x19GetCurrentSessionResponse\x12&\n" +
"\x04user\x18\x01 \x01(\v2\x12.memos.api.v1.UserR\x04user\x129\n" +
"\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\xe0\x03\n" +
"\x04user\x18\x01 \x01(\v2\x12.memos.api.v1.UserR\x04user\x12D\n" +
"\x10last_accessed_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0elastAccessedAt\"\xb8\x03\n" +
"\x14CreateSessionRequest\x12k\n" +
"\x14password_credentials\x18\x01 \x01(\v26.memos.api.v1.CreateSessionRequest.PasswordCredentialsH\x00R\x13passwordCredentials\x12\\\n" +
"\x0fsso_credentials\x18\x02 \x01(\v21.memos.api.v1.CreateSessionRequest.SSOCredentialsH\x00R\x0essoCredentials\x12&\n" +
"\fnever_expire\x18\x03 \x01(\bB\x03\xe0A\x01R\vneverExpire\x1aW\n" +
"\x0fsso_credentials\x18\x02 \x01(\v21.memos.api.v1.CreateSessionRequest.SSOCredentialsH\x00R\x0essoCredentials\x1aW\n" +
"\x13PasswordCredentials\x12\x1f\n" +
"\busername\x18\x01 \x01(\tB\x03\xe0A\x02R\busername\x12\x1f\n" +
"\bpassword\x18\x02 \x01(\tB\x03\xe0A\x02R\bpassword\x1am\n" +
......@@ -445,11 +435,10 @@ const file_api_v1_auth_service_proto_rawDesc = "" +
"\x06idp_id\x18\x01 \x01(\x05B\x03\xe0A\x02R\x05idpId\x12\x17\n" +
"\x04code\x18\x02 \x01(\tB\x03\xe0A\x02R\x04code\x12&\n" +
"\fredirect_uri\x18\x03 \x01(\tB\x03\xe0A\x02R\vredirectUriB\r\n" +
"\vcredentials\"z\n" +
"\vcredentials\"\x85\x01\n" +
"\x15CreateSessionResponse\x12&\n" +
"\x04user\x18\x01 \x01(\v2\x12.memos.api.v1.UserR\x04user\x129\n" +
"\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\x16\n" +
"\x04user\x18\x01 \x01(\v2\x12.memos.api.v1.UserR\x04user\x12D\n" +
"\x10last_accessed_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0elastAccessedAt\"\x16\n" +
"\x14DeleteSessionRequest2\x8b\x03\n" +
"\vAuthService\x12\x8b\x01\n" +
"\x11GetCurrentSession\x12&.memos.api.v1.GetCurrentSessionRequest\x1a'.memos.api.v1.GetCurrentSessionResponse\"%\x82\xd3\xe4\x93\x02\x1f\x12\x1d/api/v1/auth/sessions/current\x12z\n" +
......@@ -484,11 +473,11 @@ var file_api_v1_auth_service_proto_goTypes = []any{
}
var file_api_v1_auth_service_proto_depIdxs = []int32{
7, // 0: memos.api.v1.GetCurrentSessionResponse.user:type_name -> memos.api.v1.User
8, // 1: memos.api.v1.GetCurrentSessionResponse.expires_at:type_name -> google.protobuf.Timestamp
8, // 1: memos.api.v1.GetCurrentSessionResponse.last_accessed_at:type_name -> google.protobuf.Timestamp
5, // 2: memos.api.v1.CreateSessionRequest.password_credentials:type_name -> memos.api.v1.CreateSessionRequest.PasswordCredentials
6, // 3: memos.api.v1.CreateSessionRequest.sso_credentials:type_name -> memos.api.v1.CreateSessionRequest.SSOCredentials
7, // 4: memos.api.v1.CreateSessionResponse.user:type_name -> memos.api.v1.User
8, // 5: memos.api.v1.CreateSessionResponse.expires_at:type_name -> google.protobuf.Timestamp
8, // 5: memos.api.v1.CreateSessionResponse.last_accessed_at:type_name -> google.protobuf.Timestamp
0, // 6: memos.api.v1.AuthService.GetCurrentSession:input_type -> memos.api.v1.GetCurrentSessionRequest
2, // 7: memos.api.v1.AuthService.CreateSession:input_type -> memos.api.v1.CreateSessionRequest
4, // 8: memos.api.v1.AuthService.DeleteSession:input_type -> memos.api.v1.DeleteSessionRequest
......
This diff is collapsed.
......@@ -3282,21 +3282,18 @@ definitions:
ssoCredentials:
$ref: '#/definitions/CreateSessionRequestSSOCredentials'
description: SSO provider authentication method.
neverExpire:
type: boolean
description: |-
Whether the session should never expire.
Optional field that defaults to false for security.
v1CreateSessionResponse:
type: object
properties:
user:
$ref: '#/definitions/v1User'
description: The authenticated user information.
expiresAt:
lastAccessedAt:
type: string
format: date-time
description: Token expiration time.
description: |-
Last time the session was accessed.
Used for sliding expiration calculation (last_accessed_time + 2 weeks).
v1EmbeddedContentNode:
type: object
properties:
......@@ -3316,10 +3313,12 @@ definitions:
properties:
user:
$ref: '#/definitions/v1User'
expiresAt:
lastAccessedAt:
type: string
format: date-time
description: Current session expiration time (if available).
description: |-
Last time the session was accessed.
Used for sliding expiration calculation (last_accessed_time + 2 weeks).
v1HTMLElementNode:
type: object
properties:
......@@ -4152,15 +4151,12 @@ definitions:
format: date-time
description: The timestamp when the session was created.
readOnly: true
expireTime:
type: string
format: date-time
description: The timestamp when the session expires.
readOnly: true
lastAccessedTime:
type: string
format: date-time
description: The timestamp when the session was last accessed.
description: |-
The timestamp when the session was last accessed.
Used for sliding expiration calculation (last_accessed_time + 2 weeks).
readOnly: true
clientInfo:
$ref: '#/definitions/v1UserSessionClientInfo'
......
......@@ -476,12 +476,11 @@ type SessionsUserSetting_Session struct {
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
// Timestamp when the session was created.
CreateTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
// Timestamp when the session expires.
ExpireTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"`
// Timestamp when the session was last accessed.
LastAccessedTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=last_accessed_time,json=lastAccessedTime,proto3" json:"last_accessed_time,omitempty"`
// Used for sliding expiration calculation (last_accessed_time + 2 weeks).
LastAccessedTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=last_accessed_time,json=lastAccessedTime,proto3" json:"last_accessed_time,omitempty"`
// Client information associated with this session.
ClientInfo *SessionsUserSetting_ClientInfo `protobuf:"bytes,5,opt,name=client_info,json=clientInfo,proto3" json:"client_info,omitempty"`
ClientInfo *SessionsUserSetting_ClientInfo `protobuf:"bytes,4,opt,name=client_info,json=clientInfo,proto3" json:"client_info,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
......@@ -530,13 +529,6 @@ func (x *SessionsUserSetting_Session) GetCreateTime() *timestamppb.Timestamp {
return nil
}
func (x *SessionsUserSetting_Session) GetExpireTime() *timestamppb.Timestamp {
if x != nil {
return x.ExpireTime
}
return nil
}
func (x *SessionsUserSetting_Session) GetLastAccessedTime() *timestamppb.Timestamp {
if x != nil {
return x.LastAccessedTime
......@@ -836,18 +828,16 @@ const file_store_user_setting_proto_rawDesc = "" +
"\n" +
"appearance\x18\x02 \x01(\tR\n" +
"appearance\x12'\n" +
"\x0fmemo_visibility\x18\x03 \x01(\tR\x0ememoVisibility\"\xb0\x04\n" +
"\x0fmemo_visibility\x18\x03 \x01(\tR\x0ememoVisibility\"\xf3\x03\n" +
"\x13SessionsUserSetting\x12D\n" +
"\bsessions\x18\x01 \x03(\v2(.memos.store.SessionsUserSetting.SessionR\bsessions\x1a\xba\x02\n" +
"\bsessions\x18\x01 \x03(\v2(.memos.store.SessionsUserSetting.SessionR\bsessions\x1a\xfd\x01\n" +
"\aSession\x12\x1d\n" +
"\n" +
"session_id\x18\x01 \x01(\tR\tsessionId\x12;\n" +
"\vcreate_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"createTime\x12;\n" +
"\vexpire_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"expireTime\x12H\n" +
"\x12last_accessed_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10lastAccessedTime\x12L\n" +
"\vclient_info\x18\x05 \x01(\v2+.memos.store.SessionsUserSetting.ClientInfoR\n" +
"createTime\x12H\n" +
"\x12last_accessed_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x10lastAccessedTime\x12L\n" +
"\vclient_info\x18\x04 \x01(\v2+.memos.store.SessionsUserSetting.ClientInfoR\n" +
"clientInfo\x1a\x95\x01\n" +
"\n" +
"ClientInfo\x12\x1d\n" +
......@@ -919,14 +909,13 @@ var file_store_user_setting_proto_depIdxs = []int32{
10, // 8: memos.store.ShortcutsUserSetting.shortcuts:type_name -> memos.store.ShortcutsUserSetting.Shortcut
11, // 9: memos.store.WebhooksUserSetting.webhooks:type_name -> memos.store.WebhooksUserSetting.Webhook
12, // 10: memos.store.SessionsUserSetting.Session.create_time:type_name -> google.protobuf.Timestamp
12, // 11: memos.store.SessionsUserSetting.Session.expire_time:type_name -> google.protobuf.Timestamp
12, // 12: memos.store.SessionsUserSetting.Session.last_accessed_time:type_name -> google.protobuf.Timestamp
8, // 13: memos.store.SessionsUserSetting.Session.client_info:type_name -> memos.store.SessionsUserSetting.ClientInfo
14, // [14:14] is the sub-list for method output_type
14, // [14:14] is the sub-list for method input_type
14, // [14:14] is the sub-list for extension type_name
14, // [14:14] is the sub-list for extension extendee
0, // [0:14] is the sub-list for field type_name
12, // 11: memos.store.SessionsUserSetting.Session.last_accessed_time:type_name -> google.protobuf.Timestamp
8, // 12: memos.store.SessionsUserSetting.Session.client_info:type_name -> memos.store.SessionsUserSetting.ClientInfo
13, // [13:13] is the sub-list for method output_type
13, // [13:13] is the sub-list for method input_type
13, // [13:13] is the sub-list for extension type_name
13, // [13:13] is the sub-list for extension extendee
0, // [0:13] is the sub-list for field type_name
}
func init() { file_store_user_setting_proto_init() }
......
......@@ -48,12 +48,11 @@ message SessionsUserSetting {
string session_id = 1;
// Timestamp when the session was created.
google.protobuf.Timestamp create_time = 2;
// Timestamp when the session expires.
google.protobuf.Timestamp expire_time = 3;
// Timestamp when the session was last accessed.
google.protobuf.Timestamp last_accessed_time = 4;
// Used for sliding expiration calculation (last_accessed_time + 2 weeks).
google.protobuf.Timestamp last_accessed_time = 3;
// Client information associated with this session.
ClientInfo client_info = 5;
ClientInfo client_info = 4;
}
message ClientInfo {
......
......@@ -203,13 +203,16 @@ func (in *GRPCAuthInterceptor) updateSessionLastAccessed(ctx context.Context, us
return in.Store.UpdateUserSessionLastAccessed(ctx, userID, sessionID, timestamppb.Now())
}
// validateUserSession checks if a session exists and is still valid.
// validateUserSession checks if a session exists and is still valid using sliding expiration.
func validateUserSession(sessionID string, userSessions []*storepb.SessionsUserSetting_Session) bool {
for _, session := range userSessions {
if sessionID == session.SessionId {
// Check if session has expired
if session.ExpireTime != nil && session.ExpireTime.AsTime().Before(time.Now()) {
return false
// Use sliding expiration: check if last_accessed_time + 2 weeks > current_time
if session.LastAccessedTime != nil {
expirationTime := session.LastAccessedTime.AsTime().Add(SessionSlidingDuration)
if expirationTime.Before(time.Now()) {
return false
}
}
return true
}
......
......@@ -19,11 +19,10 @@ const (
KeyID = "v1"
// AccessTokenAudienceName is the audience name of the access token.
AccessTokenAudienceName = "user.access-token"
AccessTokenDuration = 7 * 24 * time.Hour
// SessionSlidingDuration is the sliding expiration duration for user sessions (2 weeks).
// Sessions are considered valid if last_accessed_time + SessionSlidingDuration > current_time.
SessionSlidingDuration = 14 * 24 * time.Hour
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
CookieExpDuration = AccessTokenDuration - 1*time.Minute
// SessionCookieName is the cookie name of user session ID.
SessionCookieName = "user_session"
)
......
......@@ -42,16 +42,20 @@ func (s *APIV1Service) GetCurrentSession(ctx context.Context, _ *v1pb.GetCurrent
return nil, status.Errorf(codes.Unauthenticated, "user not found")
}
// Update session last accessed time if we have a session ID
var lastAccessedAt *timestamppb.Timestamp
// Update session last accessed time if we have a session ID and get the current session info
if sessionID, ok := ctx.Value(sessionIDContextKey).(string); ok && sessionID != "" {
if err := s.Store.UpdateUserSessionLastAccessed(ctx, user.ID, sessionID, timestamppb.Now()); err != nil {
now := timestamppb.Now()
if err := s.Store.UpdateUserSessionLastAccessed(ctx, user.ID, sessionID, now); err != nil {
// Log error but don't fail the request
slog.Error("failed to update session last accessed time", "error", err)
}
lastAccessedAt = now
}
return &v1pb.GetCurrentSessionResponse{
User: convertUserFromStore(user),
User: convertUserFromStore(user),
LastAccessedAt: lastAccessedAt,
}, nil
}
......@@ -167,18 +171,15 @@ func (s *APIV1Service) CreateSession(ctx context.Context, request *v1pb.CreateSe
return nil, status.Errorf(codes.PermissionDenied, "user has been archived with username %s", existingUser.Username)
}
expireTime := time.Now().Add(AccessTokenDuration)
if request.NeverExpire {
// Set the expire time to 100 years.
expireTime = time.Now().Add(100 * 365 * 24 * time.Hour)
}
// Default session expiration time is 100 year
expireTime := time.Now().Add(100 * 365 * 24 * time.Hour)
if err := s.doSignIn(ctx, existingUser, expireTime); err != nil {
return nil, status.Errorf(codes.Internal, "failed to sign in, error: %v", err)
}
return &v1pb.CreateSessionResponse{
User: convertUserFromStore(existingUser),
ExpiresAt: timestamppb.New(expireTime),
User: convertUserFromStore(existingUser),
LastAccessedAt: timestamppb.Now(),
}, nil
}
......@@ -190,7 +191,7 @@ func (s *APIV1Service) doSignIn(ctx context.Context, user *store.User, expireTim
}
// Track session in user settings
if err := s.trackUserSession(ctx, user.ID, sessionID, expireTime); err != nil {
if err := s.trackUserSession(ctx, user.ID, sessionID); err != nil {
// Log the error but don't fail the login if session tracking fails
// This ensures backward compatibility
slog.Error("failed to track user session", "error", err)
......@@ -308,14 +309,13 @@ func (s *APIV1Service) GetCurrentUser(ctx context.Context) (*store.User, error)
}
// Helper function to track user session for session management.
func (s *APIV1Service) trackUserSession(ctx context.Context, userID int32, sessionID string, expireTime time.Time) error {
func (s *APIV1Service) trackUserSession(ctx context.Context, userID int32, sessionID string) error {
// Extract client information from the context
clientInfo := s.extractClientInfo(ctx)
session := &storepb.SessionsUserSetting_Session{
SessionId: sessionID,
CreateTime: timestamppb.Now(),
ExpireTime: timestamppb.New(expireTime),
LastAccessedTime: timestamppb.Now(),
ClientInfo: clientInfo,
}
......
......@@ -650,7 +650,6 @@ func (s *APIV1Service) ListUserSessions(ctx context.Context, request *v1pb.ListU
Name: fmt.Sprintf("users/%d/sessions/%s", userID, userSession.SessionId),
SessionId: userSession.SessionId,
CreateTime: userSession.CreateTime,
ExpireTime: userSession.ExpireTime,
LastAccessedTime: userSession.LastAccessedTime,
}
......@@ -715,7 +714,6 @@ func (s *APIV1Service) UpsertUserSession(ctx context.Context, userID int32, sess
session := &storepb.SessionsUserSetting_Session{
SessionId: sessionID,
CreateTime: timestamppb.Now(),
ExpireTime: timestamppb.New(time.Now().Add(30 * 24 * time.Hour)), // 30 days default
LastAccessedTime: timestamppb.Now(),
ClientInfo: clientInfo,
}
......
import { Button, Checkbox, Input } from "@usememos/mui";
import { Button, Input } from "@usememos/mui";
import { LoaderIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { ClientError } from "nice-grpc-web";
......@@ -17,7 +17,6 @@ const PasswordSignInForm = observer(() => {
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState(workspaceStore.state.profile.mode === "demo" ? "yourselfhosted" : "");
const [password, setPassword] = useState(workspaceStore.state.profile.mode === "demo" ? "yourselfhosted" : "");
const [remember, setRemember] = useState(true);
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
......@@ -47,7 +46,6 @@ const PasswordSignInForm = observer(() => {
actionBtnLoadingState.setLoading();
await authServiceClient.createSession({
passwordCredentials: { username, password },
neverExpire: remember,
});
await initialUserStore();
navigateTo("/");
......@@ -94,9 +92,6 @@ const PasswordSignInForm = observer(() => {
/>
</div>
</div>
<div className="flex flex-row justify-start items-center w-full mt-6">
<Checkbox label={t("common.remember-me")} checked={remember} onChange={(e) => setRemember(e.target.checked)} />
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
<Button
type="submit"
......
......@@ -60,6 +60,18 @@ const UserSessionsSection = () => {
return parts.length > 0 ? parts.join(" • ") : "Unknown Device";
};
const getSessionExpirationDate = (session: UserSession) => {
if (!session.lastAccessedTime) return null;
// Calculate expiration as last_accessed_time + 2 weeks (14 days)
const expirationDate = new Date(session.lastAccessedTime.getTime() + 14 * 24 * 60 * 60 * 1000);
return expirationDate;
};
const isSessionExpired = (session: UserSession) => {
const expirationDate = getSessionExpirationDate(session);
return expirationDate ? expirationDate < new Date() : false;
};
const isCurrentSession = (session: UserSession) => {
// A simple heuristic: the most recently accessed session is likely the current one
if (userSessions.length === 0) return false;
......@@ -126,7 +138,13 @@ const UserSessionsSection = () => {
</div>
</td>
<td className="whitespace-nowrap px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
{userSession.expireTime?.toLocaleString() ?? t("setting.user-sessions-section.never")}
<div className="flex items-center space-x-1">
<ClockIcon className="w-4 h-4" />
<span>
{getSessionExpirationDate(userSession)?.toLocaleString() ?? t("setting.user-sessions-section.never")}
{isSessionExpired(userSession) && <span className="ml-2 text-red-600 text-xs">(Expired)</span>}
</span>
</div>
</td>
<td className="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm">
<Button
......
......@@ -253,7 +253,7 @@
},
"user-sessions-section": {
"title": "Active Sessions",
"description": "A list of all active sessions for your account. You can revoke any session except the current one.",
"description": "A list of all active sessions for your account. Sessions automatically expire 2 weeks after the last activity. You can revoke any session except the current one.",
"device": "Device",
"location": "Location",
"last-active": "Last Active",
......@@ -442,4 +442,4 @@
"rename-tag": "Rename tag",
"rename-tip": "All your memos with this tag will be updated."
}
}
\ No newline at end of file
}
......@@ -19,8 +19,11 @@ export interface GetCurrentSessionResponse {
user?:
| User
| undefined;
/** Current session expiration time (if available). */
expiresAt?: Date | undefined;
/**
* Last time the session was accessed.
* Used for sliding expiration calculation (last_accessed_time + 2 weeks).
*/
lastAccessedAt?: Date | undefined;
}
export interface CreateSessionRequest {
......@@ -29,14 +32,7 @@ export interface CreateSessionRequest {
| CreateSessionRequest_PasswordCredentials
| undefined;
/** SSO provider authentication method. */
ssoCredentials?:
| CreateSessionRequest_SSOCredentials
| undefined;
/**
* Whether the session should never expire.
* Optional field that defaults to false for security.
*/
neverExpire: boolean;
ssoCredentials?: CreateSessionRequest_SSOCredentials | undefined;
}
/** Nested message for password-based authentication credentials. */
......@@ -77,8 +73,11 @@ export interface CreateSessionResponse {
user?:
| User
| undefined;
/** Token expiration time. */
expiresAt?: Date | undefined;
/**
* Last time the session was accessed.
* Used for sliding expiration calculation (last_accessed_time + 2 weeks).
*/
lastAccessedAt?: Date | undefined;
}
export interface DeleteSessionRequest {
......@@ -119,7 +118,7 @@ export const GetCurrentSessionRequest: MessageFns<GetCurrentSessionRequest> = {
};
function createBaseGetCurrentSessionResponse(): GetCurrentSessionResponse {
return { user: undefined, expiresAt: undefined };
return { user: undefined, lastAccessedAt: undefined };
}
export const GetCurrentSessionResponse: MessageFns<GetCurrentSessionResponse> = {
......@@ -127,8 +126,8 @@ export const GetCurrentSessionResponse: MessageFns<GetCurrentSessionResponse> =
if (message.user !== undefined) {
User.encode(message.user, writer.uint32(10).fork()).join();
}
if (message.expiresAt !== undefined) {
Timestamp.encode(toTimestamp(message.expiresAt), writer.uint32(18).fork()).join();
if (message.lastAccessedAt !== undefined) {
Timestamp.encode(toTimestamp(message.lastAccessedAt), writer.uint32(18).fork()).join();
}
return writer;
},
......@@ -153,7 +152,7 @@ export const GetCurrentSessionResponse: MessageFns<GetCurrentSessionResponse> =
break;
}
message.expiresAt = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
message.lastAccessedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
continue;
}
}
......@@ -171,13 +170,13 @@ export const GetCurrentSessionResponse: MessageFns<GetCurrentSessionResponse> =
fromPartial(object: DeepPartial<GetCurrentSessionResponse>): GetCurrentSessionResponse {
const message = createBaseGetCurrentSessionResponse();
message.user = (object.user !== undefined && object.user !== null) ? User.fromPartial(object.user) : undefined;
message.expiresAt = object.expiresAt ?? undefined;
message.lastAccessedAt = object.lastAccessedAt ?? undefined;
return message;
},
};
function createBaseCreateSessionRequest(): CreateSessionRequest {
return { passwordCredentials: undefined, ssoCredentials: undefined, neverExpire: false };
return { passwordCredentials: undefined, ssoCredentials: undefined };
}
export const CreateSessionRequest: MessageFns<CreateSessionRequest> = {
......@@ -188,9 +187,6 @@ export const CreateSessionRequest: MessageFns<CreateSessionRequest> = {
if (message.ssoCredentials !== undefined) {
CreateSessionRequest_SSOCredentials.encode(message.ssoCredentials, writer.uint32(18).fork()).join();
}
if (message.neverExpire !== false) {
writer.uint32(24).bool(message.neverExpire);
}
return writer;
},
......@@ -217,14 +213,6 @@ export const CreateSessionRequest: MessageFns<CreateSessionRequest> = {
message.ssoCredentials = CreateSessionRequest_SSOCredentials.decode(reader, reader.uint32());
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.neverExpire = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
......@@ -245,7 +233,6 @@ export const CreateSessionRequest: MessageFns<CreateSessionRequest> = {
message.ssoCredentials = (object.ssoCredentials !== undefined && object.ssoCredentials !== null)
? CreateSessionRequest_SSOCredentials.fromPartial(object.ssoCredentials)
: undefined;
message.neverExpire = object.neverExpire ?? false;
return message;
},
};
......@@ -379,7 +366,7 @@ export const CreateSessionRequest_SSOCredentials: MessageFns<CreateSessionReques
};
function createBaseCreateSessionResponse(): CreateSessionResponse {
return { user: undefined, expiresAt: undefined };
return { user: undefined, lastAccessedAt: undefined };
}
export const CreateSessionResponse: MessageFns<CreateSessionResponse> = {
......@@ -387,8 +374,8 @@ export const CreateSessionResponse: MessageFns<CreateSessionResponse> = {
if (message.user !== undefined) {
User.encode(message.user, writer.uint32(10).fork()).join();
}
if (message.expiresAt !== undefined) {
Timestamp.encode(toTimestamp(message.expiresAt), writer.uint32(18).fork()).join();
if (message.lastAccessedAt !== undefined) {
Timestamp.encode(toTimestamp(message.lastAccessedAt), writer.uint32(18).fork()).join();
}
return writer;
},
......@@ -413,7 +400,7 @@ export const CreateSessionResponse: MessageFns<CreateSessionResponse> = {
break;
}
message.expiresAt = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
message.lastAccessedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
continue;
}
}
......@@ -431,7 +418,7 @@ export const CreateSessionResponse: MessageFns<CreateSessionResponse> = {
fromPartial(object: DeepPartial<CreateSessionResponse>): CreateSessionResponse {
const message = createBaseCreateSessionResponse();
message.user = (object.user !== undefined && object.user !== null) ? User.fromPartial(object.user) : undefined;
message.expiresAt = object.expiresAt ?? undefined;
message.lastAccessedAt = object.lastAccessedAt ?? undefined;
return message;
},
};
......
......@@ -365,11 +365,10 @@ export interface UserSession {
createTime?:
| Date
| undefined;
/** The timestamp when the session expires. */
expireTime?:
| Date
| undefined;
/** The timestamp when the session was last accessed. */
/**
* The timestamp when the session was last accessed.
* Used for sliding expiration calculation (last_accessed_time + 2 weeks).
*/
lastAccessedTime?:
| Date
| undefined;
......@@ -2073,14 +2072,7 @@ export const DeleteUserAccessTokenRequest: MessageFns<DeleteUserAccessTokenReque
};
function createBaseUserSession(): UserSession {
return {
name: "",
sessionId: "",
createTime: undefined,
expireTime: undefined,
lastAccessedTime: undefined,
clientInfo: undefined,
};
return { name: "", sessionId: "", createTime: undefined, lastAccessedTime: undefined, clientInfo: undefined };
}
export const UserSession: MessageFns<UserSession> = {
......@@ -2094,14 +2086,11 @@ export const UserSession: MessageFns<UserSession> = {
if (message.createTime !== undefined) {
Timestamp.encode(toTimestamp(message.createTime), writer.uint32(26).fork()).join();
}
if (message.expireTime !== undefined) {
Timestamp.encode(toTimestamp(message.expireTime), writer.uint32(34).fork()).join();
}
if (message.lastAccessedTime !== undefined) {
Timestamp.encode(toTimestamp(message.lastAccessedTime), writer.uint32(42).fork()).join();
Timestamp.encode(toTimestamp(message.lastAccessedTime), writer.uint32(34).fork()).join();
}
if (message.clientInfo !== undefined) {
UserSession_ClientInfo.encode(message.clientInfo, writer.uint32(50).fork()).join();
UserSession_ClientInfo.encode(message.clientInfo, writer.uint32(42).fork()).join();
}
return writer;
},
......@@ -2142,7 +2131,7 @@ export const UserSession: MessageFns<UserSession> = {
break;
}
message.expireTime = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
message.lastAccessedTime = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
continue;
}
case 5: {
......@@ -2150,14 +2139,6 @@ export const UserSession: MessageFns<UserSession> = {
break;
}
message.lastAccessedTime = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.clientInfo = UserSession_ClientInfo.decode(reader, reader.uint32());
continue;
}
......@@ -2178,7 +2159,6 @@ export const UserSession: MessageFns<UserSession> = {
message.name = object.name ?? "";
message.sessionId = object.sessionId ?? "";
message.createTime = object.createTime ?? undefined;
message.expireTime = object.expireTime ?? undefined;
message.lastAccessedTime = object.lastAccessedTime ?? undefined;
message.clientInfo = (object.clientInfo !== undefined && object.clientInfo !== null)
? UserSession_ClientInfo.fromPartial(object.clientInfo)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment