/** * Auth: login & registration workflow. * This file contains abstractions over auth concepts. * @module soapbox/actions/auth * @see module:soapbox/actions/apps * @see module:soapbox/actions/oauth * @see module:soapbox/actions/security */ import { defineMessages } from 'react-intl'; import { createAccount } from 'soapbox/actions/accounts'; import { createApp } from 'soapbox/actions/apps'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import { startOnboarding } from 'soapbox/actions/onboarding'; import { custom } from 'soapbox/custom'; import { queryClient } from 'soapbox/queries/client'; import KVStore from 'soapbox/storage/kv-store'; import toast from 'soapbox/toast'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; import { normalizeUsername } from 'soapbox/utils/input'; import { getScopes } from 'soapbox/utils/scopes'; import { isStandalone } from 'soapbox/utils/state'; import api, { baseClient } from '../api'; import { importFetchedAccount } from './importer'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; export const AUTH_APP_CREATED = 'AUTH_APP_CREATED'; export const AUTH_APP_AUTHORIZED = 'AUTH_APP_AUTHORIZED'; export const AUTH_LOGGED_IN = 'AUTH_LOGGED_IN'; export const AUTH_LOGGED_OUT = 'AUTH_LOGGED_OUT'; export const VERIFY_CREDENTIALS_REQUEST = 'VERIFY_CREDENTIALS_REQUEST'; export const VERIFY_CREDENTIALS_SUCCESS = 'VERIFY_CREDENTIALS_SUCCESS'; export const VERIFY_CREDENTIALS_FAIL = 'VERIFY_CREDENTIALS_FAIL'; export const AUTH_ACCOUNT_REMEMBER_REQUEST = 'AUTH_ACCOUNT_REMEMBER_REQUEST'; export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS'; export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL'; const customApp = custom('app'); export const messages = defineMessages({ loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, awaitingApproval: { id: 'auth.awaiting_approval', defaultMessage: 'Your account is awaiting approval' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, }); const noOp = () => new Promise(f => f(undefined)); const createAppAndToken = () => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => dispatch(createAppToken()), ); /** Create an auth app, or use it from build config */ const getAuthApp = () => (dispatch: AppDispatch) => { if (customApp?.client_secret) { return noOp().then(() => dispatch({ type: AUTH_APP_CREATED, app: customApp })); } else { return dispatch(createAuthApp()); } }; const createAuthApp = () => (dispatch: AppDispatch, getState: () => RootState) => { const params = { client_name: sourceCode.displayName, redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', scopes: getScopes(getState()), website: sourceCode.homepage, }; return dispatch(createApp(params)).then((app: Record) => dispatch({ type: AUTH_APP_CREATED, app }), ); }; const createAppToken = () => (dispatch: AppDispatch, getState: () => RootState) => { const app = getState().auth.app; const params = { client_id: app.client_id!, client_secret: app.client_secret!, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'client_credentials', scope: getScopes(getState()), }; return dispatch(obtainOAuthToken(params)).then((token: Record) => dispatch({ type: AUTH_APP_AUTHORIZED, app, token }), ); }; const createUserToken = (username: string, password: string) => (dispatch: AppDispatch, getState: () => RootState) => { const app = getState().auth.app; const params = { client_id: app.client_id!, client_secret: app.client_secret!, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'password', username: username, password: password, scope: getScopes(getState()), }; return dispatch(obtainOAuthToken(params)) .then((token: Record) => dispatch(authLoggedIn(token))); }; export const otpVerify = (code: string, mfa_token: string) => (dispatch: AppDispatch, getState: () => RootState) => { const app = getState().auth.app; return api(getState, 'app').post('/oauth/mfa/challenge', { client_id: app.client_id, client_secret: app.client_secret, mfa_token: mfa_token, code: code, challenge_type: 'totp', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', scope: getScopes(getState()), }).then(({ data: token }) => dispatch(authLoggedIn(token))); }; export const verifyCredentials = (token: string, accountUrl?: string) => { const baseURL = parseBaseURL(accountUrl); return (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: VERIFY_CREDENTIALS_REQUEST, token }); return baseClient(token, baseURL).get('/api/v1/accounts/verify_credentials').then(({ data: account }) => { dispatch(importFetchedAccount(account)); dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); if (account.id === getState().me) dispatch(fetchMeSuccess(account)); return account; }).catch(error => { if (error?.response?.status === 403 && error?.response?.data?.id) { // The user is waitlisted const account = error.response.data; dispatch(importFetchedAccount(account)); dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); if (account.id === getState().me) dispatch(fetchMeSuccess(account)); return account; } else { if (getState().me === null) dispatch(fetchMeFail(error)); dispatch({ type: VERIFY_CREDENTIALS_FAIL, token, error }); throw error; } }); }; }; export const rememberAuthAccount = (accountUrl: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: AUTH_ACCOUNT_REMEMBER_REQUEST, accountUrl }); return KVStore.getItemOrError(`authAccount:${accountUrl}`).then(account => { dispatch(importFetchedAccount(account)); dispatch({ type: AUTH_ACCOUNT_REMEMBER_SUCCESS, account, accountUrl }); if (account.id === getState().me) dispatch(fetchMeSuccess(account)); return account; }).catch(error => { dispatch({ type: AUTH_ACCOUNT_REMEMBER_FAIL, error, accountUrl, skipAlert: true }); }); }; export const loadCredentials = (token: string, accountUrl: string) => (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) .then(() => dispatch(verifyCredentials(token, accountUrl))) .catch(() => dispatch(verifyCredentials(token, accountUrl))); export const logIn = (username: string, password: string) => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { return dispatch(createUserToken(normalizeUsername(username), password)); }).catch((error: AxiosError) => { if ((error.response?.data as any)?.error === 'mfa_required') { // If MFA is required, throw the error and handle it in the component. throw error; } else if ((error.response?.data as any)?.identifier === 'awaiting_approval') { toast.error(messages.awaitingApproval); } else { // Return "wrong password" message. toast.error(messages.invalidCredentials); } throw error; }); export const deleteSession = () => (dispatch: AppDispatch, getState: () => RootState) => api(getState).delete('/api/sign_out'); export const logOut = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const account = getLoggedInAccount(state); const standalone = isStandalone(state); if (!account) return dispatch(noOp); const params = { client_id: state.auth.app.client_id!, client_secret: state.auth.app.client_secret!, token: state.auth.users.get(account.url)!.access_token, }; return dispatch(revokeOAuthToken(params)) .finally(() => { // Clear all stored cache from React Query queryClient.invalidateQueries(); queryClient.clear(); dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); toast.success(messages.loggedOut); }); }; export const switchAccount = (accountId: string, background = false) => (dispatch: AppDispatch, getState: () => RootState) => { const account = getState().accounts.get(accountId); // Clear all stored cache from React Query queryClient.invalidateQueries(); queryClient.clear(); return dispatch({ type: SWITCH_ACCOUNT, account, background }); }; export const fetchOwnAccounts = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); return state.auth.users.forEach((user) => { const account = state.accounts.get(user.id); if (!account) { dispatch(verifyCredentials(user.access_token, user.url)); } }); }; export const register = (params: Record) => (dispatch: AppDispatch) => { params.fullname = params.username; return dispatch(createAppAndToken()) .then(() => dispatch(createAccount(params))) .then(({ token }: { token: Record }) => { dispatch(startOnboarding()); return dispatch(authLoggedIn(token)); }); }; export const fetchCaptcha = () => (_dispatch: AppDispatch, getState: () => RootState) => { return api(getState).get('/api/pleroma/captcha'); }; export const authLoggedIn = (token: Record) => (dispatch: AppDispatch) => { dispatch({ type: AUTH_LOGGED_IN, token }); return token; };