diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index e8463a833..c0c112c24 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -4,6 +4,7 @@ import { importFetchedAccount } from './importer'; import snackbar from 'soapbox/actions/snackbar'; import { createAccount } from 'soapbox/actions/accounts'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; +import { getLoggedInAccount } from 'soapbox/utils/auth'; export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; @@ -176,21 +177,24 @@ export function logIn(intl, username, password) { export function logOut(intl) { return (dispatch, getState) => { const state = getState(); - const me = state.get('me'); + const account = getLoggedInAccount(state); return api(getState).post('/oauth/revoke', { client_id: state.getIn(['auth', 'app', 'client_id']), client_secret: state.getIn(['auth', 'app', 'client_secret']), - token: state.getIn(['auth', 'users', me, 'access_token']), + token: state.getIn(['auth', 'users', account.get('url'), 'access_token']), }).finally(() => { - dispatch({ type: AUTH_LOGGED_OUT, accountId: me }); + dispatch({ type: AUTH_LOGGED_OUT, account }); dispatch(snackbar.success(intl.formatMessage(messages.loggedOut))); }); }; } export function switchAccount(accountId, background = false) { - return { type: SWITCH_ACCOUNT, accountId, background }; + return (dispatch, getState) => { + const account = getState().getIn(['accounts', accountId]); + dispatch({ type: SWITCH_ACCOUNT, account, background }); + }; } export function fetchOwnAccounts() { @@ -258,13 +262,15 @@ export function changeEmail(email, password) { export function deleteAccount(intl, password) { return (dispatch, getState) => { + const account = getLoggedInAccount(getState()); + dispatch({ type: DELETE_ACCOUNT_REQUEST }); return api(getState).post('/api/pleroma/delete_account', { password, }).then(response => { if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure dispatch({ type: DELETE_ACCOUNT_SUCCESS, response }); - dispatch({ type: AUTH_LOGGED_OUT }); + dispatch({ type: AUTH_LOGGED_OUT, account }); dispatch(snackbar.success(intl.formatMessage(messages.loggedOut))); }).catch(error => { dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true }); diff --git a/app/soapbox/actions/me.js b/app/soapbox/actions/me.js index 240e26bf8..13a6f86ce 100644 --- a/app/soapbox/actions/me.js +++ b/app/soapbox/actions/me.js @@ -1,6 +1,7 @@ import api from '../api'; import { importFetchedAccount } from './importer'; import { verifyCredentials } from './auth'; +import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth'; export const ME_FETCH_REQUEST = 'ME_FETCH_REQUEST'; export const ME_FETCH_SUCCESS = 'ME_FETCH_SUCCESS'; @@ -13,12 +14,22 @@ export const ME_PATCH_FAIL = 'ME_PATCH_FAIL'; const noOp = () => new Promise(f => f()); +const getMeId = state => state.get('me') || getAuthUserId(state); + +const getMeUrl = state => { + const accountId = getMeId(state); + return state.getIn(['accounts', accountId, 'url']) || getAuthUserUrl(state); +}; + +const getMeToken = state => { + // Fallback for upgrading IDs to URLs + const accountUrl = getMeUrl(state) || state.getIn(['auth', 'me']); + return state.getIn(['auth', 'users', accountUrl, 'access_token']); +}; + export function fetchMe() { return (dispatch, getState) => { - const state = getState(); - - const me = state.get('me') || state.getIn(['auth', 'me']); - const token = state.getIn(['auth', 'users', me, 'access_token']); + const token = getMeToken(getState()); if (!token) { dispatch({ type: ME_FETCH_SKIP }); return noOp(); diff --git a/app/soapbox/reducers/__tests__/auth-test.js b/app/soapbox/reducers/__tests__/auth-test.js index 6b367bf85..74dc04283 100644 --- a/app/soapbox/reducers/__tests__/auth-test.js +++ b/app/soapbox/reducers/__tests__/auth-test.js @@ -65,17 +65,20 @@ describe('auth reducer', () => { describe('AUTH_LOGGED_OUT', () => { it('deletes the user', () => { - const action = { type: AUTH_LOGGED_OUT, accountId: '1234' }; + const action = { + type: AUTH_LOGGED_OUT, + account: fromJS({ url: 'https://gleasonator.com/users/alex' }), + }; const state = fromJS({ users: { - '1234': { id: '1234', access_token: 'ABCDEFG' }, - '5678': { id: '5678', access_token: 'HIJKLMN' }, + 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, + 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, }); const expected = fromJS({ - '5678': { id: '5678', access_token: 'HIJKLMN' }, + 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }); const result = reducer(state, action); @@ -84,16 +87,20 @@ describe('auth reducer', () => { it('sets `me` to the next available user', () => { const state = fromJS({ - me: '1234', + me: 'https://gleasonator.com/users/alex', users: { - '1234': { id: '1234', access_token: 'ABCDEFG' }, - '5678': { id: '5678', access_token: 'HIJKLMN' }, + 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, + 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, }); - const action = { type: AUTH_LOGGED_OUT, accountId: '1234' }; + const action = { + type: AUTH_LOGGED_OUT, + account: fromJS({ url: 'https://gleasonator.com/users/alex' }), + }; + const result = reducer(state, action); - expect(result.get('me')).toEqual('5678'); + expect(result.get('me')).toEqual('https://gleasonator.com/users/benis'); }); }); @@ -102,11 +109,11 @@ describe('auth reducer', () => { const action = { type: VERIFY_CREDENTIALS_SUCCESS, token: 'ABCDEFG', - account: { id: '1234' }, + account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; const expected = fromJS({ - '1234': { id: '1234', access_token: 'ABCDEFG' }, + 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, }); const result = reducer(undefined, action); @@ -117,7 +124,7 @@ describe('auth reducer', () => { const action = { type: VERIFY_CREDENTIALS_SUCCESS, token: 'ABCDEFG', - account: { id: '1234' }, + account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; const state = fromJS({ @@ -125,7 +132,12 @@ describe('auth reducer', () => { }); const expected = fromJS({ - 'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG', account: '1234' }, + 'ABCDEFG': { + token_type: 'Bearer', + access_token: 'ABCDEFG', + account: '1234', + me: 'https://gleasonator.com/users/alex', + }, }); const result = reducer(state, action); @@ -136,49 +148,82 @@ describe('auth reducer', () => { const action = { type: VERIFY_CREDENTIALS_SUCCESS, token: 'ABCDEFG', - account: { id: '1234' }, + account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; const result = reducer(undefined, action); - expect(result.get('me')).toEqual('1234'); + expect(result.get('me')).toEqual('https://gleasonator.com/users/alex'); }); it('leaves `me` alone if already set', () => { const action = { type: VERIFY_CREDENTIALS_SUCCESS, token: 'ABCDEFG', - account: { id: '1234' }, + account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; - const state = fromJS({ me: '5678' }); + const state = fromJS({ me: 'https://gleasonator.com/users/benis' }); const result = reducer(state, action); - expect(result.get('me')).toEqual('5678'); + expect(result.get('me')).toEqual('https://gleasonator.com/users/benis'); }); it('deletes mismatched users', () => { const action = { type: VERIFY_CREDENTIALS_SUCCESS, token: 'ABCDEFG', - account: { id: '1234' }, + account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; const state = fromJS({ users: { - '4567': { id: '4567', access_token: 'ABCDEFG' }, - '8901': { id: '1234', access_token: 'ABCDEFG' }, - '5432': { id: '5432', access_token: 'HIJKLMN' }, + 'https://gleasonator.com/users/mk': { id: '4567', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/mk' }, + 'https://gleasonator.com/users/curtis': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/curtis' }, + 'https://gleasonator.com/users/benis': { id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, }); const expected = fromJS({ - '1234': { id: '1234', access_token: 'ABCDEFG' }, - '5432': { id: '5432', access_token: 'HIJKLMN' }, + 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, + 'https://gleasonator.com/users/benis': { id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }); const result = reducer(state, action); expect(result.get('users')).toEqual(expected); }); + + it('upgrades from an ID to a URL', () => { + const action = { + type: VERIFY_CREDENTIALS_SUCCESS, + token: 'ABCDEFG', + account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, + }; + + const state = fromJS({ + me: '1234', + users: { + '1234': { id: '1234', access_token: 'ABCDEFG' }, + '5432': { id: '5432', access_token: 'HIJKLMN' }, + }, + tokens: { + 'ABCDEFG': { access_token: 'ABCDEFG', account: '1234' }, + }, + }); + + const expected = fromJS({ + me: 'https://gleasonator.com/users/alex', + users: { + 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, + '5432': { id: '5432', access_token: 'HIJKLMN' }, + }, + tokens: { + 'ABCDEFG': { access_token: 'ABCDEFG', account: '1234', me: 'https://gleasonator.com/users/alex' }, + }, + }); + + const result = reducer(state, action); + expect(result).toEqual(expected); + }); }); describe('VERIFY_CREDENTIALS_FAIL', () => { @@ -207,13 +252,13 @@ describe('auth reducer', () => { it('should delete any users associated with the failed token', () => { const state = fromJS({ users: { - '1234': { id: '1234', access_token: 'ABCDEFG' }, - '5678': { id: '5678', access_token: 'HIJKLMN' }, + 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, + 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, }); const expected = fromJS({ - '5678': { id: '5678', access_token: 'HIJKLMN' }, + 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }); const action = { @@ -228,10 +273,10 @@ describe('auth reducer', () => { it('should reassign `me` to the next in line', () => { const state = fromJS({ - me: '1234', + me: 'https://gleasonator.com/users/alex', users: { - '1234': { id: '1234', access_token: 'ABCDEFG' }, - '5678': { id: '5678', access_token: 'HIJKLMN' }, + 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, + 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, }); @@ -242,21 +287,25 @@ describe('auth reducer', () => { }; const result = reducer(state, action); - expect(result.get('me')).toEqual('5678'); + expect(result.get('me')).toEqual('https://gleasonator.com/users/benis'); }); }); describe('SWITCH_ACCOUNT', () => { it('sets the value of `me`', () => { - const action = { type: SWITCH_ACCOUNT, accountId: '5678' }; + const action = { + type: SWITCH_ACCOUNT, + account: fromJS({ url: 'https://gleasonator.com/users/benis' }), + }; + const result = reducer(undefined, action); - expect(result.get('me')).toEqual('5678'); + expect(result.get('me')).toEqual('https://gleasonator.com/users/benis'); }); }); describe('ME_FETCH_SKIP', () => { it('sets `me` to null', () => { - const state = fromJS({ me: '1234' }); + const state = fromJS({ me: 'https://gleasonator.com/users/alex' }); const action = { type: ME_FETCH_SKIP }; const result = reducer(state, action); expect(result.get('me')).toEqual(null); diff --git a/app/soapbox/reducers/auth.js b/app/soapbox/reducers/auth.js index 88cdd1a08..d332125a7 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.js @@ -9,6 +9,7 @@ import { } from '../actions/auth'; import { ME_FETCH_SKIP } from '../actions/me'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import { validId, isURL } from 'soapbox/utils/auth'; const defaultState = ImmutableMap({ app: ImmutableMap(), @@ -17,8 +18,6 @@ const defaultState = ImmutableMap({ me: null, }); -const validId = id => typeof id === 'string' && id !== 'null' && id !== 'undefined'; - const getSessionUser = () => { const id = sessionStorage.getItem('soapbox:auth:me'); return validId(id) ? id : undefined; @@ -39,14 +38,24 @@ const validUser = user => { // Finds the first valid user in the state const firstValidUser = state => state.get('users', ImmutableMap()).find(validUser); +// For legacy purposes. IDs get upgraded to URLs further down. +const getUrlOrId = user => { + try { + const { id, url } = user.toJS(); + return url || id; + } catch { + return null; + } +}; + // If `me` doesn't match an existing user, attempt to shift it. const maybeShiftMe = state => { - const users = state.get('users', ImmutableMap()); const me = state.get('me'); + const user = state.getIn(['users', me]); - if (!validUser(users.get(me))) { + if (!validUser(user)) { const nextUser = firstValidUser(state); - return state.set('me', nextUser ? nextUser.get('id') : null); + return state.set('me', getUrlOrId(nextUser)); } else { return state; } @@ -59,7 +68,7 @@ const setSessionUser = state => state.update('me', null, me => { state.getIn(['users', me]), ]).find(validUser); - return user ? user.get('id') : null; + return getUrlOrId(user); }); // Upgrade the initial state @@ -83,13 +92,22 @@ const migrateLegacy = state => { }); }; +const isUpgradingUrlId = state => { + const me = state.get('me'); + const user = state.getIn(['users', me]); + return validId(me) && user && !isURL(me); +}; + // Checks the state and makes it valid const sanitizeState = state => { + // Skip sanitation during ID to URL upgrade + if (isUpgradingUrlId(state)) return state; + return state.withMutations(state => { // Remove invalid users, ensure ID match state.update('users', ImmutableMap(), users => ( - users.filter((user, id) => ( - validUser(user) && user.get('id') === id + users.filter((user, url) => ( + validUser(user) && user.get('url') === url )) )); // Remove mismatched tokens @@ -135,33 +153,49 @@ const importToken = (state, token) => { const upgradeLegacyId = (state, account) => { if (localState) return state; return state.withMutations(state => { - state.update('me', null, me => me === '_legacy' ? account.id : me); + state.update('me', null, me => me === '_legacy' ? account.url : me); state.deleteIn(['users', '_legacy']); }); // TODO: Delete `soapbox:auth:app` and `soapbox:auth:user` localStorage? // By this point it's probably safe, but we'll leave it just in case. }; +// Users are now stored by their ActivityPub ID instead of their +// primary key to support auth against multiple hosts. +const upgradeNonUrlId = (state, account) => { + const me = state.get('me'); + if (isURL(me)) return state; + + return state.withMutations(state => { + state.update('me', null, me => me === account.id ? account.url : me); + state.deleteIn(['users', account.id]); + }); +}; + // Returns a predicate function for filtering a mismatched user/token const userMismatch = (token, account) => { - return (user, id) => { + return (user, url) => { const sameToken = user.get('access_token') === token; - const differentId = id !== account.id || user.get('id') !== account.id; + const differentUrl = url !== account.url || user.get('url') !== account.url; + const differentId = user.get('id') !== account.id; - return sameToken && differentId; + return sameToken && (differentUrl || differentId); }; }; const importCredentials = (state, token, account) => { return state.withMutations(state => { - state.setIn(['users', account.id], ImmutableMap({ + state.setIn(['users', account.url], ImmutableMap({ id: account.id, access_token: token, + url: account.url, })); state.setIn(['tokens', token, 'account'], account.id); + state.setIn(['tokens', token, 'me'], account.url); state.update('users', ImmutableMap(), users => users.filterNot(userMismatch(token, account))); - state.update('me', null, me => me || account.id); + state.update('me', null, me => me || account.url); upgradeLegacyId(state, account); + upgradeNonUrlId(state, account); }); }; @@ -173,10 +207,12 @@ const deleteToken = (state, token) => { }); }; -const deleteUser = (state, accountId) => { +const deleteUser = (state, account) => { + const accountUrl = account.get('url'); + return state.withMutations(state => { - state.update('users', ImmutableMap(), users => users.delete(accountId)); - state.update('tokens', ImmutableMap(), tokens => tokens.filterNot(token => token.get('account') === accountId)); + state.update('users', ImmutableMap(), users => users.delete(accountUrl)); + state.update('tokens', ImmutableMap(), tokens => tokens.filterNot(token => token.get('me') === accountUrl)); maybeShiftMe(state); }); }; @@ -190,13 +226,13 @@ const reducer = (state, action) => { case AUTH_LOGGED_IN: return importToken(state, action.token); case AUTH_LOGGED_OUT: - return deleteUser(state, action.accountId); + return deleteUser(state, action.account); case VERIFY_CREDENTIALS_SUCCESS: return importCredentials(state, action.token, action.account); case VERIFY_CREDENTIALS_FAIL: return action.error.response.status === 403 ? deleteToken(state, action.token) : state; case SWITCH_ACCOUNT: - return state.set('me', action.accountId); + return state.set('me', action.account.get('url')); case ME_FETCH_SKIP: return state.set('me', null); default: @@ -214,10 +250,14 @@ const validMe = state => { // `me` has changed from one valid ID to another const userSwitched = (oldState, state) => { - const stillValid = validMe(oldState) && validMe(state); - const didChange = oldState.get('me') !== state.get('me'); + const me = state.get('me'); + const oldMe = oldState.get('me'); - return stillValid && didChange; + const stillValid = validMe(oldState) && validMe(state); + const didChange = oldMe !== me; + const userUpgradedUrl = state.getIn(['users', me, 'id']) === oldMe; + + return stillValid && didChange && !userUpgradedUrl; }; const maybeReload = (oldState, state, action) => { diff --git a/app/soapbox/selectors/index.js b/app/soapbox/selectors/index.js index 8a5bf3b20..faa6ed1b3 100644 --- a/app/soapbox/selectors/index.js +++ b/app/soapbox/selectors/index.js @@ -8,6 +8,7 @@ import { getDomain } from 'soapbox/utils/accounts'; import ConfigDB from 'soapbox/utils/config_db'; import { getSettings } from 'soapbox/actions/settings'; import { shouldFilter } from 'soapbox/utils/timelines'; +import { validId } from 'soapbox/utils/auth'; const getAccountBase = (state, id) => state.getIn(['accounts', id], null); const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); @@ -207,15 +208,27 @@ export const makeGetReport = () => { ); }; +const getAuthUserIds = createSelector([ + state => state.getIn(['auth', 'users'], ImmutableMap()), +], authUsers => { + return authUsers.reduce((ids, authUser) => { + try { + const id = authUser.get('id'); + return validId(id) ? ids.add(id) : ids; + } catch { + return ids; + } + }, ImmutableOrderedSet()); +}); + export const makeGetOtherAccounts = () => { return createSelector([ state => state.get('accounts'), - state => state.getIn(['auth', 'users']), + getAuthUserIds, state => state.get('me'), ], - (accounts, authUsers, me) => { - return authUsers - .keySeq() + (accounts, authUserIds, me) => { + return authUserIds .reduce((list, id) => { if (id === me) return list; const account = accounts.get(id); diff --git a/app/soapbox/utils/auth.js b/app/soapbox/utils/auth.js index 28f232711..b4af627c0 100644 --- a/app/soapbox/utils/auth.js +++ b/app/soapbox/utils/auth.js @@ -1,14 +1,51 @@ +import { List as ImmutableList } from 'immutable'; + +export const validId = id => typeof id === 'string' && id !== 'null' && id !== 'undefined'; + +export const isURL = url => { + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +export const getLoggedInAccount = state => { + const me = state.get('me'); + return state.getIn(['accounts', me]); +}; + export const isLoggedIn = getState => { - return typeof getState().get('me') === 'string'; + return validId(getState().get('me')); }; export const getAppToken = state => state.getIn(['auth', 'app', 'access_token']); export const getUserToken = (state, accountId) => { - return state.getIn(['auth', 'users', accountId, 'access_token']); + const accountUrl = state.getIn(['accounts', accountId, 'url']); + return state.getIn(['auth', 'users', accountUrl, 'access_token']); }; export const getAccessToken = state => { const me = state.get('me'); return getUserToken(state, me); }; + +export const getAuthUserId = state => { + const me = state.getIn(['auth', 'me']); + + return ImmutableList([ + state.getIn(['auth', 'users', me, 'id']), + me, + ]).find(validId); +}; + +export const getAuthUserUrl = state => { + const me = state.getIn(['auth', 'me']); + + return ImmutableList([ + state.getIn(['auth', 'users', me, 'url']), + me, + ]).find(isURL); +};