fix(server): change password with token should be public (#7855)

This commit is contained in:
forehalo 2024-08-14 03:34:35 +00:00
parent 7afba6b8b5
commit 0ba516866f
No known key found for this signature in database
GPG Key ID: 56709255DC7EC728
12 changed files with 78 additions and 114 deletions

View File

@ -16,6 +16,7 @@ import {
EmailTokenNotFound, EmailTokenNotFound,
EmailVerificationRequired, EmailVerificationRequired,
InvalidEmailToken, InvalidEmailToken,
LinkExpired,
SameEmailProvided, SameEmailProvided,
SkipThrottle, SkipThrottle,
Throttle, Throttle,
@ -89,12 +90,17 @@ export class AuthResolver {
}; };
} }
@Mutation(() => UserType) @Public()
@Mutation(() => Boolean)
async changePassword( async changePassword(
@CurrentUser() user: CurrentUser,
@Args('token') token: string, @Args('token') token: string,
@Args('newPassword') newPassword: string @Args('newPassword') newPassword: string,
@Args('userId', { type: () => String, nullable: true }) userId?: string
) { ) {
if (!userId) {
throw new LinkExpired();
}
const config = await this.config.runtime.fetchAll({ const config = await this.config.runtime.fetchAll({
'auth/password.max': true, 'auth/password.max': true,
'auth/password.min': true, 'auth/password.min': true,
@ -108,7 +114,7 @@ export class AuthResolver {
TokenType.ChangePassword, TokenType.ChangePassword,
token, token,
{ {
credential: user.id, credential: userId,
} }
); );
@ -116,10 +122,10 @@ export class AuthResolver {
throw new InvalidEmailToken(); throw new InvalidEmailToken();
} }
await this.auth.changePassword(user.id, newPassword); await this.auth.changePassword(userId, newPassword);
await this.auth.revokeUserSessions(user.id); await this.auth.revokeUserSessions(userId);
return user; return true;
} }
@Mutation(() => UserType) @Mutation(() => UserType)
@ -163,7 +169,7 @@ export class AuthResolver {
user.id user.id
); );
const url = this.url.link(callbackUrl, { token }); const url = this.url.link(callbackUrl, { userId: user.id, token });
const res = await this.auth.sendChangePasswordEmail(user.email, url); const res = await this.auth.sendChangePasswordEmail(user.email, url);
@ -176,19 +182,7 @@ export class AuthResolver {
@Args('callbackUrl') callbackUrl: string, @Args('callbackUrl') callbackUrl: string,
@Args('email', { nullable: true }) _email?: string @Args('email', { nullable: true }) _email?: string
) { ) {
if (!user.emailVerified) { return this.sendChangePasswordEmail(user, callbackUrl);
throw new EmailVerificationRequired();
}
const token = await this.token.createToken(
TokenType.ChangePassword,
user.id
);
const url = this.url.link(callbackUrl, { token });
const res = await this.auth.sendSetPasswordEmail(user.email, url);
return !res.rejected.length;
} }
// The change email step is: // The change email step is:
@ -305,6 +299,7 @@ export class AuthResolver {
TokenType.ChangePassword, TokenType.ChangePassword,
userId userId
); );
return this.url.link(callbackUrl, { token });
return this.url.link(callbackUrl, { userId, token });
} }
} }

View File

@ -279,6 +279,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'invalid_input', type: 'invalid_input',
message: 'An invalid email token provided.', message: 'An invalid email token provided.',
}, },
link_expired: {
type: 'bad_request',
message: 'The link has expired.',
},
// Authentication & Permission Errors // Authentication & Permission Errors
authentication_required: { authentication_required: {

View File

@ -137,6 +137,12 @@ export class InvalidEmailToken extends UserFriendlyError {
} }
} }
export class LinkExpired extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'link_expired', message);
}
}
export class AuthenticationRequired extends UserFriendlyError { export class AuthenticationRequired extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
super('authentication_required', 'authentication_required', message); super('authentication_required', 'authentication_required', message);
@ -520,6 +526,7 @@ export enum ErrorNames {
SIGN_UP_FORBIDDEN, SIGN_UP_FORBIDDEN,
EMAIL_TOKEN_NOT_FOUND, EMAIL_TOKEN_NOT_FOUND,
INVALID_EMAIL_TOKEN, INVALID_EMAIL_TOKEN,
LINK_EXPIRED,
AUTHENTICATION_REQUIRED, AUTHENTICATION_REQUIRED,
ACTION_FORBIDDEN, ACTION_FORBIDDEN,
ACCESS_DENIED, ACCESS_DENIED,

View File

@ -235,6 +235,7 @@ enum ErrorNames {
INVALID_OAUTH_CALLBACK_STATE INVALID_OAUTH_CALLBACK_STATE
INVALID_PASSWORD_LENGTH INVALID_PASSWORD_LENGTH
INVALID_RUNTIME_CONFIG_TYPE INVALID_RUNTIME_CONFIG_TYPE
LINK_EXPIRED
MAILER_SERVICE_IS_NOT_CONFIGURED MAILER_SERVICE_IS_NOT_CONFIGURED
MEMBER_QUOTA_EXCEEDED MEMBER_QUOTA_EXCEEDED
MISSING_OAUTH_QUERY_PARAMETER MISSING_OAUTH_QUERY_PARAMETER
@ -409,7 +410,7 @@ type Mutation {
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription! cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
changeEmail(email: String!, token: String!): UserType! changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!): UserType! changePassword(newPassword: String!, token: String!, userId: String): Boolean!
"""Cleanup sessions""" """Cleanup sessions"""
cleanupCopilotSession(options: DeleteSessionInput!): [String!]! cleanupCopilotSession(options: DeleteSessionInput!): [String!]!

View File

@ -132,13 +132,14 @@ test('set and change password', async t => {
); );
const newPassword = randomBytes(16).toString('hex'); const newPassword = randomBytes(16).toString('hex');
const userId = await changePassword( const success = await changePassword(
app, app,
u1.token.token, u1.id,
setPasswordToken as string, setPasswordToken as string,
newPassword newPassword
); );
t.is(u1.id, userId, 'failed to set password');
t.true(success, 'failed to change password');
const ret = auth.signIn(u1Email, newPassword); const ret = auth.signIn(u1Email, newPassword);
t.notThrowsAsync(ret, 'failed to check password'); t.notThrowsAsync(ret, 'failed to check password');
@ -201,7 +202,7 @@ test('should revoke token after change user identify', async t => {
await sendSetPasswordEmail(app, u3.token.token, u3Email, 'affine.pro'); await sendSetPasswordEmail(app, u3.token.token, u3Email, 'affine.pro');
const token = await getTokenFromLatestMailMessage(); const token = await getTokenFromLatestMailMessage();
const newPassword = randomBytes(16).toString('hex'); const newPassword = randomBytes(16).toString('hex');
await changePassword(app, u3.token.token, token as string, newPassword); await changePassword(app, u3.id, token as string, newPassword);
const user = await currentUser(app, u3.token.token); const user = await currentUser(app, u3.token.token);
t.is(user, null, 'token should be revoked'); t.is(user, null, 'token should be revoked');

View File

@ -129,26 +129,23 @@ export async function sendSetPasswordEmail(
export async function changePassword( export async function changePassword(
app: INestApplication, app: INestApplication,
userToken: string, userId: string,
token: string, token: string,
password: string password: string
): Promise<string> { ): Promise<string> {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post(gql) .post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) .set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({ .send({
query: ` query: `
mutation changePassword($token: String!, $password: String!) { mutation changePassword($token: String!, $userId: String!, $password: String!) {
changePassword(token: $token, newPassword: $password) { changePassword(token: $token, userId: $userId, newPassword: $password)
id
}
} }
`, `,
variables: { token, password }, variables: { token, password, userId },
}) })
.expect(200); .expect(200);
return res.body.data.changePassword.id; return res.body.data.changePassword;
} }
export async function sendVerifyChangeEmail( export async function sendVerifyChangeEmail(

View File

@ -7,19 +7,12 @@ import { Button } from '../../ui/button';
import { notify } from '../../ui/notification'; import { notify } from '../../ui/notification';
import { AuthPageContainer } from './auth-page-container'; import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password'; import { SetPassword } from './set-password';
import type { User } from './type';
export const ChangePasswordPage: FC<{ export const ChangePasswordPage: FC<{
user: User;
passwordLimits: PasswordLimitsFragment; passwordLimits: PasswordLimitsFragment;
onSetPassword: (password: string) => Promise<void>; onSetPassword: (password: string) => Promise<void>;
onOpenAffine: () => void; onOpenAffine: () => void;
}> = ({ }> = ({ passwordLimits, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
user: { email },
passwordLimits,
onSetPassword: propsOnSetPassword,
onOpenAffine,
}) => {
const t = useI18n(); const t = useI18n();
const [hasSetUp, setHasSetUp] = useState(false); const [hasSetUp, setHasSetUp] = useState(false);
@ -45,17 +38,12 @@ export const ChangePasswordPage: FC<{
: t['com.affine.auth.reset.password.page.title']() : t['com.affine.auth.reset.password.page.title']()
} }
subtitle={ subtitle={
hasSetUp ? ( hasSetUp
t['com.affine.auth.sent.reset.password.success.message']() ? t['com.affine.auth.sent.reset.password.success.message']()
) : ( : t['com.affine.auth.page.sent.email.subtitle']({
<>
{t['com.affine.auth.page.sent.email.subtitle']({
min: String(passwordLimits.minLength), min: String(passwordLimits.minLength),
max: String(passwordLimits.maxLength), max: String(passwordLimits.maxLength),
})} })
<a href={`mailto:${email}`}>{email}</a>
</>
)
} }
> >
{hasSetUp ? ( {hasSetUp ? (

View File

@ -7,19 +7,12 @@ import { Button } from '../../ui/button';
import { notify } from '../../ui/notification'; import { notify } from '../../ui/notification';
import { AuthPageContainer } from './auth-page-container'; import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password'; import { SetPassword } from './set-password';
import type { User } from './type';
export const SetPasswordPage: FC<{ export const SetPasswordPage: FC<{
user: User;
passwordLimits: PasswordLimitsFragment; passwordLimits: PasswordLimitsFragment;
onSetPassword: (password: string) => Promise<void>; onSetPassword: (password: string) => Promise<void>;
onOpenAffine: () => void; onOpenAffine: () => void;
}> = ({ }> = ({ passwordLimits, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
user: { email },
passwordLimits,
onSetPassword: propsOnSetPassword,
onOpenAffine,
}) => {
const t = useI18n(); const t = useI18n();
const [hasSetUp, setHasSetUp] = useState(false); const [hasSetUp, setHasSetUp] = useState(false);
@ -45,17 +38,12 @@ export const SetPasswordPage: FC<{
: t['com.affine.auth.set.password.page.title']() : t['com.affine.auth.set.password.page.title']()
} }
subtitle={ subtitle={
hasSetUp ? ( hasSetUp
t['com.affine.auth.sent.set.password.success.message']() ? t['com.affine.auth.sent.set.password.success.message']()
) : ( : t['com.affine.auth.page.sent.email.subtitle']({
<>
{t['com.affine.auth.page.sent.email.subtitle']({
min: String(passwordLimits.minLength), min: String(passwordLimits.minLength),
max: String(passwordLimits.maxLength), max: String(passwordLimits.maxLength),
})} })
<a href={`mailto:${email}`}>{email}</a>
</>
)
} }
> >
{hasSetUp ? ( {hasSetUp ? (

View File

@ -17,8 +17,7 @@ import {
} from '@affine/graphql'; } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import type { ReactElement } from 'react'; import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import type { LoaderFunction } from 'react-router-dom'; import type { LoaderFunction } from 'react-router-dom';
import { redirect, useParams, useSearchParams } from 'react-router-dom'; import { redirect, useParams, useSearchParams } from 'react-router-dom';
import { z } from 'zod'; import { z } from 'zod';
@ -39,7 +38,7 @@ const authTypeSchema = z.enum([
'verify-email', 'verify-email',
]); ]);
export const AuthPage = (): ReactElement | null => { export const Component = () => {
const authService = useService(AuthService); const authService = useService(AuthService);
const account = useLiveData(authService.session.account$); const account = useLiveData(authService.session.account$);
const t = useI18n(); const t = useI18n();
@ -89,6 +88,7 @@ export const AuthPage = (): ReactElement | null => {
async (password: string) => { async (password: string) => {
await changePassword({ await changePassword({
token: searchParams.get('token') || '', token: searchParams.get('token') || '',
userId: searchParams.get('userId') || '',
newPassword: password, newPassword: password,
}); });
}, },
@ -98,22 +98,26 @@ export const AuthPage = (): ReactElement | null => {
jumpToIndex(RouteLogic.REPLACE); jumpToIndex(RouteLogic.REPLACE);
}, [jumpToIndex]); }, [jumpToIndex]);
if (!passwordLimits || !account) { if (!passwordLimits) {
// TODO(@eyhn): loading UI // TODO(@eyhn): loading UI
return null; return null;
} }
switch (authType) { switch (authType) {
case 'onboarding': case 'onboarding':
return <OnboardingPage user={account} onOpenAffine={onOpenAffine} />; return (
account && <OnboardingPage user={account} onOpenAffine={onOpenAffine} />
);
case 'signUp': { case 'signUp': {
return ( return (
<SignUpPage account && (
user={account} <SignUpPage
passwordLimits={passwordLimits} user={account}
onSetPassword={onSetPassword} passwordLimits={passwordLimits}
onOpenAffine={onOpenAffine} onSetPassword={onSetPassword}
/> onOpenAffine={onOpenAffine}
/>
)
); );
} }
case 'signIn': { case 'signIn': {
@ -122,7 +126,6 @@ export const AuthPage = (): ReactElement | null => {
case 'changePassword': { case 'changePassword': {
return ( return (
<ChangePasswordPage <ChangePasswordPage
user={account}
passwordLimits={passwordLimits} passwordLimits={passwordLimits}
onSetPassword={onSetPassword} onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine} onOpenAffine={onOpenAffine}
@ -132,7 +135,6 @@ export const AuthPage = (): ReactElement | null => {
case 'setPassword': { case 'setPassword': {
return ( return (
<SetPasswordPage <SetPasswordPage
user={account}
passwordLimits={passwordLimits} passwordLimits={passwordLimits}
onSetPassword={onSetPassword} onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine} onOpenAffine={onOpenAffine}
@ -198,25 +200,3 @@ export const loader: LoaderFunction = async args => {
} }
return null; return null;
}; };
export const Component = () => {
const authService = useService(AuthService);
const isRevalidating = useLiveData(authService.session.isRevalidating$);
const loginStatus = useLiveData(authService.session.status$);
const { jumpToExpired } = useNavigateHelper();
useEffect(() => {
authService.session.revalidate();
}, [authService]);
if (loginStatus === 'unauthenticated' && !isRevalidating) {
jumpToExpired(RouteLogic.REPLACE);
}
if (loginStatus === 'authenticated') {
return <AuthPage />;
}
// TODO(@eyhn): loading UI
return null;
};

View File

@ -1,5 +1,7 @@
mutation changePassword($token: String!, $newPassword: String!) { mutation changePassword(
changePassword(token: $token, newPassword: $newPassword) { $token: String!
id $userId: String!
} $newPassword: String!
) {
changePassword(token: $token, userId: $userId, newPassword: $newPassword)
} }

View File

@ -121,10 +121,8 @@ export const changePasswordMutation = {
definitionName: 'changePassword', definitionName: 'changePassword',
containsFile: false, containsFile: false,
query: ` query: `
mutation changePassword($token: String!, $newPassword: String!) { mutation changePassword($token: String!, $userId: String!, $newPassword: String!) {
changePassword(token: $token, newPassword: $newPassword) { changePassword(token: $token, userId: $userId, newPassword: $newPassword)
id
}
}`, }`,
}; };

View File

@ -306,6 +306,7 @@ export enum ErrorNames {
INVALID_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE', INVALID_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE',
INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH', INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH',
INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE', INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE',
LINK_EXPIRED = 'LINK_EXPIRED',
MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED', MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED',
MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED', MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED',
MISSING_OAUTH_QUERY_PARAMETER = 'MISSING_OAUTH_QUERY_PARAMETER', MISSING_OAUTH_QUERY_PARAMETER = 'MISSING_OAUTH_QUERY_PARAMETER',
@ -467,7 +468,7 @@ export interface Mutation {
addWorkspaceFeature: Scalars['Int']['output']; addWorkspaceFeature: Scalars['Int']['output'];
cancelSubscription: UserSubscription; cancelSubscription: UserSubscription;
changeEmail: UserType; changeEmail: UserType;
changePassword: UserType; changePassword: Scalars['Boolean']['output'];
/** Cleanup sessions */ /** Cleanup sessions */
cleanupCopilotSession: Array<Scalars['String']['output']>; cleanupCopilotSession: Array<Scalars['String']['output']>;
/** Create change password url */ /** Create change password url */
@ -557,6 +558,7 @@ export interface MutationChangeEmailArgs {
export interface MutationChangePasswordArgs { export interface MutationChangePasswordArgs {
newPassword: Scalars['String']['input']; newPassword: Scalars['String']['input'];
token: Scalars['String']['input']; token: Scalars['String']['input'];
userId: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationCleanupCopilotSessionArgs { export interface MutationCleanupCopilotSessionArgs {
@ -1321,12 +1323,13 @@ export type CreateChangePasswordUrlMutation = {
export type ChangePasswordMutationVariables = Exact<{ export type ChangePasswordMutationVariables = Exact<{
token: Scalars['String']['input']; token: Scalars['String']['input'];
userId: Scalars['String']['input'];
newPassword: Scalars['String']['input']; newPassword: Scalars['String']['input'];
}>; }>;
export type ChangePasswordMutation = { export type ChangePasswordMutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
changePassword: { __typename?: 'UserType'; id: string }; changePassword: boolean;
}; };
export type CopilotQuotaQueryVariables = Exact<{ [key: string]: never }>; export type CopilotQuotaQueryVariables = Exact<{ [key: string]: never }>;