Skip to content

Commit

Permalink
fix(server): change password with token should be public
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Aug 13, 2024
1 parent 42b63f2 commit 3ca562b
Show file tree
Hide file tree
Showing 10 changed files with 70 additions and 104 deletions.
39 changes: 17 additions & 22 deletions packages/backend/server/src/core/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
EmailTokenNotFound,
EmailVerificationRequired,
InvalidEmailToken,
LinkExpired,
SameEmailProvided,
SkipThrottle,
Throttle,
Expand Down Expand Up @@ -89,12 +90,17 @@ export class AuthResolver {
};
}

@Mutation(() => UserType)
@Public()
@Mutation(() => Boolean)
async changePassword(
@CurrentUser() user: CurrentUser,
@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({
'auth/password.max': true,
'auth/password.min': true,
Expand All @@ -108,18 +114,18 @@ export class AuthResolver {
TokenType.ChangePassword,
token,
{
credential: user.id,
credential: userId,
}
);

if (!valid) {
throw new InvalidEmailToken();
}

await this.auth.changePassword(user.id, newPassword);
await this.auth.revokeUserSessions(user.id);
await this.auth.changePassword(userId, newPassword);
await this.auth.revokeUserSessions(userId);

return user;
return true;
}

@Mutation(() => UserType)
Expand Down Expand Up @@ -163,7 +169,7 @@ export class AuthResolver {
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);

Expand All @@ -176,19 +182,7 @@ export class AuthResolver {
@Args('callbackUrl') callbackUrl: string,
@Args('email', { nullable: true }) _email?: string
) {
if (!user.emailVerified) {
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;
return this.sendChangePasswordEmail(user, callbackUrl);
}

// The change email step is:
Expand Down Expand Up @@ -305,6 +299,7 @@ export class AuthResolver {
TokenType.ChangePassword,
userId
);
return this.url.link(callbackUrl, { token });

return this.url.link(callbackUrl, { userId, token });
}
}
8 changes: 6 additions & 2 deletions packages/backend/server/src/fundamentals/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export class UserFriendlyError extends Error {
// disallow message override for `internal_server_error`
// to avoid leak internal information to user
let msg =
name === 'internal_server_error' ? defaultMsg : (message ?? defaultMsg);
name === 'internal_server_error' ? defaultMsg : message ?? defaultMsg;

if (typeof msg === 'function') {
msg = msg(args);
Expand Down Expand Up @@ -95,7 +95,7 @@ export class UserFriendlyError extends Error {

new Logger(context).error(
'Internal server error',
this.cause ? ((this.cause as any).stack ?? this.cause) : this.stack
this.cause ? (this.cause as any).stack ?? this.cause : this.stack
);
}
}
Expand Down Expand Up @@ -279,6 +279,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'invalid_input',
message: 'An invalid email token provided.',
},
link_expired: {
type: 'bad_request',
message: 'The link has expired.',
},

// Authentication & Permission Errors
authentication_required: {
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/server/src/fundamentals/error/errors.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
constructor(message?: string) {
super('authentication_required', 'authentication_required', message);
Expand Down Expand Up @@ -520,6 +526,7 @@ export enum ErrorNames {
SIGN_UP_FORBIDDEN,
EMAIL_TOKEN_NOT_FOUND,
INVALID_EMAIL_TOKEN,
LINK_EXPIRED,
AUTHENTICATION_REQUIRED,
ACTION_FORBIDDEN,
ACCESS_DENIED,
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/server/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ enum ErrorNames {
INVALID_OAUTH_CALLBACK_STATE
INVALID_PASSWORD_LENGTH
INVALID_RUNTIME_CONFIG_TYPE
LINK_EXPIRED
MAILER_SERVICE_IS_NOT_CONFIGURED
MEMBER_QUOTA_EXCEEDED
MISSING_OAUTH_QUERY_PARAMETER
Expand Down Expand Up @@ -409,7 +410,7 @@ type Mutation {
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!, userId: String): Boolean!

"""Cleanup sessions"""
cleanupCopilotSession(options: DeleteSessionInput!): [String!]!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,12 @@ import { Button } from '../../ui/button';
import { notify } from '../../ui/notification';
import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password';
import type { User } from './type';

export const ChangePasswordPage: FC<{
user: User;
passwordLimits: PasswordLimitsFragment;
onSetPassword: (password: string) => Promise<void>;
onOpenAffine: () => void;
}> = ({
user: { email },
passwordLimits,
onSetPassword: propsOnSetPassword,
onOpenAffine,
}) => {
}> = ({ passwordLimits, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
const t = useI18n();
const [hasSetUp, setHasSetUp] = useState(false);

Expand All @@ -45,17 +38,12 @@ export const ChangePasswordPage: FC<{
: t['com.affine.auth.reset.password.page.title']()
}
subtitle={
hasSetUp ? (
t['com.affine.auth.sent.reset.password.success.message']()
) : (
<>
{t['com.affine.auth.page.sent.email.subtitle']({
hasSetUp
? t['com.affine.auth.sent.reset.password.success.message']()
: t['com.affine.auth.page.sent.email.subtitle']({
min: String(passwordLimits.minLength),
max: String(passwordLimits.maxLength),
})}
<a href={`mailto:${email}`}>{email}</a>
</>
)
})
}
>
{hasSetUp ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,12 @@ import { Button } from '../../ui/button';
import { notify } from '../../ui/notification';
import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password';
import type { User } from './type';

export const SetPasswordPage: FC<{
user: User;
passwordLimits: PasswordLimitsFragment;
onSetPassword: (password: string) => Promise<void>;
onOpenAffine: () => void;
}> = ({
user: { email },
passwordLimits,
onSetPassword: propsOnSetPassword,
onOpenAffine,
}) => {
}> = ({ passwordLimits, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
const t = useI18n();
const [hasSetUp, setHasSetUp] = useState(false);

Expand All @@ -45,17 +38,12 @@ export const SetPasswordPage: FC<{
: t['com.affine.auth.set.password.page.title']()
}
subtitle={
hasSetUp ? (
t['com.affine.auth.sent.set.password.success.message']()
) : (
<>
{t['com.affine.auth.page.sent.email.subtitle']({
hasSetUp
? t['com.affine.auth.sent.set.password.success.message']()
: t['com.affine.auth.page.sent.email.subtitle']({
min: String(passwordLimits.minLength),
max: String(passwordLimits.maxLength),
})}
<a href={`mailto:${email}`}>{email}</a>
</>
)
})
}
>
{hasSetUp ? (
Expand Down
50 changes: 15 additions & 35 deletions packages/frontend/core/src/pages/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ import {
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useCallback, useEffect } from 'react';
import { useCallback } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect, useParams, useSearchParams } from 'react-router-dom';
import { z } from 'zod';
Expand All @@ -39,7 +38,7 @@ const authTypeSchema = z.enum([
'verify-email',
]);

export const AuthPage = (): ReactElement | null => {
export const Component = () => {
const authService = useService(AuthService);
const account = useLiveData(authService.session.account$);
const t = useI18n();
Expand Down Expand Up @@ -89,6 +88,7 @@ export const AuthPage = (): ReactElement | null => {
async (password: string) => {
await changePassword({
token: searchParams.get('token') || '',
userId: searchParams.get('userId') || '',
newPassword: password,
});
},
Expand All @@ -98,22 +98,26 @@ export const AuthPage = (): ReactElement | null => {
jumpToIndex(RouteLogic.REPLACE);
}, [jumpToIndex]);

if (!passwordLimits || !account) {
if (!passwordLimits) {
// TODO(@eyhn): loading UI
return null;
}

switch (authType) {
case 'onboarding':
return <OnboardingPage user={account} onOpenAffine={onOpenAffine} />;
return (
account && <OnboardingPage user={account} onOpenAffine={onOpenAffine} />
);
case 'signUp': {
return (
<SignUpPage
user={account}
passwordLimits={passwordLimits}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
account && (
<SignUpPage
user={account}
passwordLimits={passwordLimits}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
)
);
}
case 'signIn': {
Expand All @@ -122,7 +126,6 @@ export const AuthPage = (): ReactElement | null => {
case 'changePassword': {
return (
<ChangePasswordPage
user={account}
passwordLimits={passwordLimits}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
Expand All @@ -132,7 +135,6 @@ export const AuthPage = (): ReactElement | null => {
case 'setPassword': {
return (
<SetPasswordPage
user={account}
passwordLimits={passwordLimits}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
Expand Down Expand Up @@ -198,25 +200,3 @@ export const loader: LoaderFunction = async args => {
}
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;
};
10 changes: 6 additions & 4 deletions packages/frontend/graphql/src/graphql/change-password.gql
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mutation changePassword($token: String!, $newPassword: String!) {
changePassword(token: $token, newPassword: $newPassword) {
id
}
mutation changePassword(
$token: String!
$userId: String!
$newPassword: String!
) {
changePassword(token: $token, userId: $userId, newPassword: $newPassword)
}
6 changes: 2 additions & 4 deletions packages/frontend/graphql/src/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,8 @@ export const changePasswordMutation = {
definitionName: 'changePassword',
containsFile: false,
query: `
mutation changePassword($token: String!, $newPassword: String!) {
changePassword(token: $token, newPassword: $newPassword) {
id
}
mutation changePassword($token: String!, $userId: String!, $newPassword: String!) {
changePassword(token: $token, userId: $userId, newPassword: $newPassword)
}`,
};

Expand Down
Loading

0 comments on commit 3ca562b

Please sign in to comment.