refactor: replace refresh & access token with auth token only

changes authentication to no longer use a refresh token & access token
for accessing protected endpoints. Instead only an auth token is used.

Before the login flow was:

Login -> get refresh (stored as HttpOnly cookie) + access token (stored in memory) ->
  protected endpoint request (attach access token as Authorization header) -> access token expires in
  15 minutes, so use refresh token to obtain new one when that happens

now it looks like this:

Login -> get auth token (stored as HttpOnly cookie) -> make protected endpont
request (token sent)

the reasoning for using the refresh + access token was to reduce DB
calls, but in the end I don't think its worth the hassle.
This commit is contained in:
Jordan Knott 2021-04-28 21:32:19 -05:00
parent 3392b3345d
commit 229a53fa0a
47 changed files with 3989 additions and 3717 deletions

View File

@ -39,7 +39,7 @@
"dayjs": "^1.9.1", "dayjs": "^1.9.1",
"dompurify": "^2.2.6", "dompurify": "^2.2.6",
"emoji-mart": "^3.0.0", "emoji-mart": "^3.0.0",
"emoticon": "^3.2.0", "emoticon": "^4.0.0",
"graphql": "^15.0.0", "graphql": "^15.0.0",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",
"history": "^4.10.1", "history": "^4.10.1",

View File

@ -215,9 +215,12 @@ const AdminRoute = () => {
}, },
}); });
if (data && user) { if (data && user) {
/*
TODO: add permision check
if (user.roles.org !== 'admin') { if (user.roles.org !== 'admin') {
return <Redirect to="/" />; return <Redirect to="/" />;
} }
*/
return ( return (
<> <>
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} /> <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
@ -225,7 +228,8 @@ const AdminRoute = () => {
initialTab={0} initialTab={0}
users={data.users} users={data.users}
invitedUsers={data.invitedUsers} invitedUsers={data.invitedUsers}
canInviteUser={user.roles.org === 'admin'} // canInviteUser={user.roles.org === 'admin'} TODO: add permision check
canInviteUser={true}
onInviteUser={NOOP} onInviteUser={NOOP}
onUpdateUserPassword={() => { onUpdateUserPassword={() => {
hidePopup(); hidePopup();

View File

@ -13,8 +13,6 @@ import Login from 'Auth';
import Register from 'Register'; import Register from 'Register';
import Profile from 'Profile'; import Profile from 'Profile';
import styled from 'styled-components'; import styled from 'styled-components';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
const MainContent = styled.div` const MainContent = styled.div`
@ -26,9 +24,9 @@ const MainContent = styled.div`
flex-grow: 1; flex-grow: 1;
`; `;
type RefreshTokenResponse = { type ValidateTokenResponse = {
accessToken: string; valid: boolean;
setup?: null | { confirmToken: string }; userID: string;
}; };
const AuthorizedRoutes = () => { const AuthorizedRoutes = () => {
@ -36,27 +34,17 @@ const AuthorizedRoutes = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser(); const { setUser } = useCurrentUser();
useEffect(() => { useEffect(() => {
fetch('/auth/refresh_token', { fetch('/auth/validate', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}).then(async x => { }).then(async x => {
const { status } = x; const { status } = x;
if (status === 400) { const response: ValidateTokenResponse = await x.json();
history.replace('/login'); const { valid, userID } = response;
if (!valid) {
history.replace(`/login`);
} else { } else {
const response: RefreshTokenResponse = await x.json(); setUser(userID);
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
}
} }
setLoading(false); setLoading(false);
}); });

View File

@ -3,13 +3,8 @@ import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import { ProfileMenu } from 'shared/components/DropdownMenu'; import { ProfileMenu } from 'shared/components/DropdownMenu';
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings'; import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import { import { RoleCode, useTopNavbarQuery, useDeleteProjectMutation, GetProjectsDocument } from 'shared/generated/graphql';
RoleCode,
useTopNavbarQuery,
useDeleteProjectMutation,
GetProjectsDocument,
} from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
@ -107,23 +102,10 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
onRemoveInvitedFromBoard, onRemoveInvitedFromBoard,
onRemoveFromBoard, onRemoveFromBoard,
}) => { }) => {
const { user, setUserRoles, setUser } = useCurrentUser(); const { user, setUser } = useCurrentUser();
const { loading, data } = useTopNavbarQuery({ const { loading, data } = useTopNavbarQuery({
onCompleted: response => { // TODO: maybe remove?
if (user && user.roles) { onCompleted: response => {},
setUserRoles({
org: user.roles.org,
teams: response.me.teamRoles.reduce((map, obj) => {
map.set(obj.teamID, obj.roleCode);
return map;
}, new Map<string, string>()),
projects: response.me.projectRoles.reduce((map, obj) => {
map.set(obj.projectID, obj.roleCode);
return map;
}, new Map<string, string>()),
});
}
},
}); });
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const history = useHistory(); const history = useHistory();
@ -147,7 +129,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
<Popup title={null} tab={0}> <Popup title={null} tab={0}>
<ProfileMenu <ProfileMenu
onLogout={onLogout} onLogout={onLogout}
showAdminConsole={user ? user.roles.org === 'admin' : false} showAdminConsole={true} // TODO: add permision check
onAdminConsole={() => { onAdminConsole={() => {
history.push('/admin'); history.push('/admin');
hidePopup(); hidePopup();
@ -189,7 +171,9 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
if (!user) { if (!user) {
return null; return null;
} }
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID); // TODO: readd permision check
// const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const userIsTeamOrProjectAdmin = true;
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => { const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null; const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
if (member) { if (member) {

View File

@ -1,4 +1,4 @@
import { InMemoryCache } from 'apollo-cache-inmemory'; import { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache(); const cache = new InMemoryCache();

View File

@ -1,79 +1,20 @@
import React, { useContext } from 'react'; import React, { useContext } from 'react';
export enum PermissionLevel {
ORG,
TEAM,
PROJECT,
}
export enum PermissionObjectType {
ORG,
TEAM,
PROJECT,
TASK,
}
export type CurrentUserRoles = {
org: string;
teams: Map<string, string>;
projects: Map<string, string>;
};
export interface CurrentUserRaw {
id: string;
roles: CurrentUserRoles;
}
type UserContextState = { type UserContextState = {
user: CurrentUserRaw | null; user: string | null;
setUser: (user: CurrentUserRaw | null) => void; setUser: (user: string | null) => void;
setUserRoles: (roles: CurrentUserRoles) => void;
}; };
export const UserContext = React.createContext<UserContextState>({ export const UserContext = React.createContext<UserContextState>({
user: null, user: null,
setUser: _user => null, setUser: _user => null,
setUserRoles: roles => null,
}); });
export interface CurrentUser extends CurrentUserRaw {
isAdmin: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
isVisible: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
}
export const useCurrentUser = () => { export const useCurrentUser = () => {
const { user, setUser, setUserRoles } = useContext(UserContext); const { user, setUser } = useContext(UserContext);
let currentUser: CurrentUser | null = null;
if (user) {
currentUser = {
...user,
isAdmin(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
if (user.roles.org === 'admin') {
return true;
}
switch (level) {
case PermissionLevel.TEAM:
return subjectID ? this.roles.teams.get(subjectID) === 'admin' : false;
default:
return false;
}
},
isVisible(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
if (user.roles.org === 'admin') {
return true;
}
switch (level) {
case PermissionLevel.TEAM:
return subjectID ? this.roles.teams.get(subjectID) !== null : false;
default:
return false;
}
},
};
}
return { return {
user: currentUser, user,
setUser, setUser,
setUserRoles,
}; };
}; };

View File

@ -1,16 +1,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import jwtDecode from 'jwt-decode';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { PopupProvider } from 'shared/components/PopupMenu'; import { PopupProvider } from 'shared/components/PopupMenu';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components'; import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles'; import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles'; import BaseStyles from './BaseStyles';
import theme from './ThemeStyles'; import theme from './ThemeStyles';
import Routes from './Routes'; import Routes from './Routes';
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context'; import { UserContext } from './context';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
@ -48,19 +46,11 @@ const StyledContainer = styled(ToastContainer).attrs({
const history = createBrowserHistory(); const history = createBrowserHistory();
const App = () => { const App = () => {
const [user, setUser] = useState<CurrentUserRaw | null>(null); const [user, setUser] = useState<string | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => {
if (user) {
setUser({
...user,
roles,
});
}
};
return ( return (
<> <>
<UserContext.Provider value={{ user, setUser, setUserRoles }}> <UserContext.Provider value={{ user, setUser }}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NormalizeStyles /> <NormalizeStyles />
<BaseStyles /> <BaseStyles />

View File

@ -1,7 +1,5 @@
import React, { useState, useEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import Login from 'shared/components/Login'; import Login from 'shared/components/Login';
import UserContext from 'App/context'; import UserContext from 'App/context';
import { Container, LoginWrapper } from './Styles'; import { Container, LoginWrapper } from './Styles';
@ -30,43 +28,24 @@ const Auth = () => {
setComplete(true); setComplete(true);
} else { } else {
const response = await x.json(); const response = await x.json();
const { accessToken } = response; const { userID } = response;
const claims: JWTToken = JwtDecode(accessToken); setUser(userID);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setComplete(true);
setAccessToken(accessToken);
history.push('/'); history.push('/');
} }
}); });
}; };
useEffect(() => { useEffect(() => {
fetch('/auth/refresh_token', { fetch('/auth/validate', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}).then(async x => { }).then(async x => {
const { status } = x; const response = await x.json();
if (status === 200) { const { valid, userID } = response;
const response: RefreshTokenResponse = await x.json(); if (valid) {
const { accessToken, setup } = response; setUser(userID);
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
history.replace('/projects'); history.replace('/projects');
} }
}
}); });
}, []); }, []);

View File

@ -5,8 +5,6 @@ import { useHistory, useLocation } from 'react-router';
import * as QueryString from 'query-string'; import * as QueryString from 'query-string';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Container, LoginWrapper } from './Styles'; import { Container, LoginWrapper } from './Styles';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
const UsersConfirm = () => { const UsersConfirm = () => {
@ -31,18 +29,8 @@ const UsersConfirm = () => {
const { status } = x; const { status } = x;
if (status === 200) { if (status === 200) {
const response = await x.json(); const response = await x.json();
const { accessToken } = response; const { userID } = response;
const claims: JWTToken = JwtDecode(accessToken); setUser(userID);
const currentUser = {
id: claims.userId,
roles: {
org: claims.orgRole,
teams: new Map<string, string>(),
projects: new Map<string, string>(),
},
};
setUser(currentUser);
setAccessToken(accessToken);
history.push('/'); history.push('/');
} else { } else {
setFailed(); setFailed();

View File

@ -1,7 +1,6 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings'; import Settings from 'shared/components/Settings';
import { import {
useMeQuery, useMeQuery,
@ -49,12 +48,9 @@ const Projects = () => {
if (e.target.files) { if (e.target.files) {
const fileData = new FormData(); const fileData = new FormData();
fileData.append('file', e.target.files[0]); fileData.append('file', e.target.files[0]);
const accessToken = getAccessToken();
axios axios
.post('/users/me/avatar', fileData, { .post('/users/me/avatar', fileData, {
headers: { withCredentials: true,
Authorization: `Bearer ${accessToken}`,
},
}) })
.then(res => { .then(res => {
if ($fileUpload && $fileUpload.current) { if ($fileUpload && $fileUpload.current) {
@ -75,7 +71,7 @@ const Projects = () => {
} }
}} }}
onResetPassword={(password, done) => { onResetPassword={(password, done) => {
updateUserPassword({ variables: { userID: user.id, password } }); updateUserPassword({ variables: { userID: user, password } });
toast('Password was changed!'); toast('Password was changed!');
done(); done();
}} }}

View File

@ -543,7 +543,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onChangeTaskMetaFilter={filter => { onChangeTaskMetaFilter={filter => {
setTaskMetaFilters(filter); setTaskMetaFilters(filter);
}} }}
userID={user?.id} userID={user ?? ''}
labels={labelsRef} labels={labelsRef}
members={membersRef} members={membersRef}
/>, />,

View File

@ -541,7 +541,7 @@ const Details: React.FC<DetailsProps> = ({
bio="None" bio="None"
onRemoveFromTask={() => { onRemoveFromTask={() => {
if (user) { if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } }); unassignTask({ variables: { taskID: data.findTask.id, userID: user ?? '' } });
} }
}} }}
/> />

View File

@ -12,7 +12,7 @@ import {
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import NewProject from 'shared/components/NewProject'; import NewProject from 'shared/components/NewProject';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -268,7 +268,7 @@ const Projects = () => {
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} /> <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<Wrapper> <Wrapper>
<ProjectsContainer> <ProjectsContainer>
{user.roles.org === 'admin' && ( {true && ( // TODO: add permision check
<AddTeamButton <AddTeamButton
variant="outline" variant="outline"
onClick={$target => { onClick={$target => {
@ -330,7 +330,7 @@ const Projects = () => {
<div key={team.id}> <div key={team.id}>
<ProjectSectionTitleWrapper> <ProjectSectionTitleWrapper>
<ProjectSectionTitle>{team.name}</ProjectSectionTitle> <ProjectSectionTitle>{team.name}</ProjectSectionTitle>
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && ( {true && ( // TODO: add permision check
<SectionActions> <SectionActions>
<SectionActionLink to={`/teams/${team.id}`}> <SectionActionLink to={`/teams/${team.id}`}>
<SectionAction variant="outline">Projects</SectionAction> <SectionAction variant="outline">Projects</SectionAction>
@ -355,7 +355,7 @@ const Projects = () => {
</ProjectTile> </ProjectTile>
</ProjectListItem> </ProjectListItem>
))} ))}
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && ( {true && ( // TODO: add permision check
<ProjectListItem> <ProjectListItem>
<ProjectAddTile <ProjectAddTile
onClick={() => { onClick={() => {

View File

@ -3,7 +3,7 @@ import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import produce from 'immer'; import produce from 'immer';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { useCurrentUser, PermissionLevel, PermissionObjectType } from 'App/context'; import { useCurrentUser } from 'App/context';
import Select from 'shared/components/Select'; import Select from 'shared/components/Select';
import { import {
useGetTeamQuery, useGetTeamQuery,
@ -424,7 +424,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
pollInterval: 3000, pollInterval: 3000,
}); });
const { user, setUserRoles } = useCurrentUser(); const { user } = useCurrentUser();
const warning = const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.'; 'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
const [createTeamMember] = useCreateTeamMemberMutation({ const [createTeamMember] = useCreateTeamMemberMutation({
@ -446,17 +446,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
); );
}, },
}); });
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation({ const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation();
onCompleted: r => {
if (user) {
setUserRoles(
produce(user.roles, draftRoles => {
draftRoles.teams.set(r.updateTeamMemberRole.teamID, r.updateTeamMemberRole.member.role.code);
}),
);
}
},
});
const [deleteTeamMember] = useDeleteTeamMemberMutation({ const [deleteTeamMember] = useDeleteTeamMemberMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<GetTeamQuery>( updateApolloCache<GetTeamQuery>(
@ -491,7 +481,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
</ListDesc> </ListDesc>
<ListActions> <ListActions>
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" /> <FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, data.findTeam.id) && ( {true && ( // TODO: add permission check
<InviteMemberButton <InviteMemberButton
onClick={$target => { onClick={$target => {
showPopup( showPopup(
@ -528,11 +518,12 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
showPopup( showPopup(
$target, $target,
<TeamRoleManagerPopup <TeamRoleManagerPopup
currentUserID={user.id ?? ''} currentUserID={user ?? ''}
subject={member} subject={member}
members={data.findTeam.members} members={data.findTeam.members}
warning={member.role && member.role.code === 'owner' ? warning : null} warning={member.role && member.role.code === 'owner' ? warning : null}
canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)} // canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)} TODO: add permission check
canChangeRole={true}
onChangeRole={roleCode => { onChangeRole={roleCode => {
updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } }); updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } });
}} }}

View File

@ -13,7 +13,7 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history'; import { History } from 'history';
import produce from 'immer'; import produce from 'immer';
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings'; import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import Members from './Members'; import Members from './Members';
import Projects from './Projects'; import Projects from './Projects';
@ -95,9 +95,12 @@ const Teams = () => {
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch(); const match = useRouteMatch();
if (data && user) { if (data && user) {
/*
TODO: re-add permission check
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) { if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
return <Redirect to="/" />; return <Redirect to="/" />;
} }
*/
return ( return (
<> <>
<GlobalTopNavbar <GlobalTopNavbar

View File

@ -1,20 +1,15 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import axios from 'axios'; import { ApolloClient } from '@apollo/client';
import createAuthRefreshInterceptor from 'axios-auth-refresh'; import { ApolloProvider } from '@apollo/client/react';
import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { enableMapSet } from 'immer'; import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import updateLocale from 'dayjs/plugin/updateLocale'; import updateLocale from 'dayjs/plugin/updateLocale';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from 'dayjs/plugin/isBetween';
import weekday from 'dayjs/plugin/weekday'; import weekday from 'dayjs/plugin/weekday';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import cache from './App/cache'; import cache from './App/cache';
import App from './App'; import App from './App';
@ -34,131 +29,8 @@ dayjs.updateLocale('en', {
}, },
}); });
let forward$; const client = new ApolloClient({ uri: '/graphql', cache });
let isRefreshing = false; console.log('cloient', client);
let pendingRequests: any = [];
const refreshAuthLogic = (failedRequest: any) =>
axios.post('/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
setAccessToken(tokenRefreshResponse.data.accessToken);
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
return Promise.resolve();
});
createAuthRefreshInterceptor(axios, refreshAuthLogic);
const resolvePendingRequests = () => {
pendingRequests.map((callback: any) => callback());
pendingRequests = [];
};
const resolvePromise = (resolve: () => void) => {
pendingRequests.push(() => resolve());
};
const resetPendingRequests = () => {
pendingRequests = [];
};
const setRefreshing = (newVal: boolean) => {
isRefreshing = newVal;
};
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions && err.extensions.code) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
if (!isRefreshing) {
setRefreshing(true);
forward$ = fromPromise(
getNewToken()
.then((response: any) => {
setAccessToken(response.accessToken);
resolvePendingRequests();
return response.accessToken;
})
.catch(() => {
resetPendingRequests();
// TODO
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return undefined;
})
.finally(() => {
setRefreshing(false);
}),
).filter(value => Boolean(value));
} else {
forward$ = fromPromise(new Promise(resolvePromise));
}
return forward$.flatMap(() => forward(operation));
default:
// pass
}
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
}
return undefined;
});
const requestLink = new ApolloLink(
(operation, forward) =>
new Observable((observer: any) => {
let handle: any;
Promise.resolve(operation)
.then((op: any) => {
const accessToken = getAccessToken();
if (accessToken) {
op.setContext({
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
}
})
.then(() => {
handle = forward(operation).subscribe({
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
})
.catch(observer.error.bind(observer));
return () => {
if (handle) {
handle.unsubscribe();
}
};
}),
);
const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(
({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`), // eslint-disable-line no-console
);
}
if (networkError) {
console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
}
}),
errorLink,
requestLink,
new HttpLink({
uri: '/graphql',
credentials: 'same-origin',
}),
]),
cache,
});
ReactDOM.render( ReactDOM.render(
<ApolloProvider client={client}> <ApolloProvider client={client}>

View File

@ -1,5 +1,5 @@
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea/lib'; import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';

View File

@ -49,6 +49,7 @@ export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCa
<ListNameEditorWrapper> <ListNameEditorWrapper>
<ListNameEditor <ListNameEditor
ref={$editorRef} ref={$editorRef}
height={40}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
value={listName} value={listName}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)}

View File

@ -10,6 +10,7 @@ export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
z-index: ${props => props.zIndex}; z-index: ${props => props.zIndex};
position: relative; position: relative;
`; `;
export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>` export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>`
${props => ${props =>
props.color === 'success' && props.color === 'success' &&
@ -18,6 +19,7 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
stroke: ${props.theme.colors.success}; stroke: ${props.theme.colors.success};
`} `}
`; `;
export const ClockIcon = styled(Clock)<{ color: string }>` export const ClockIcon = styled(Clock)<{ color: string }>`
fill: ${props => props.color}; fill: ${props => props.color};
`; `;
@ -26,7 +28,7 @@ export const EditorTextarea = styled(TextareaAutosize)`
overflow: hidden; overflow: hidden;
overflow-wrap: break-word; overflow-wrap: break-word;
resize: none; resize: none;
height: 90px; height: 54px;
width: 100%; width: 100%;
background: none; background: none;

View File

@ -1,6 +1,6 @@
import visit from 'unist-util-visit'; import visit from 'unist-util-visit';
import emoji from 'node-emoji'; import emoji from 'node-emoji';
import emoticon from 'emoticon'; import { emoticon } from 'emoticon';
import { Emoji } from 'emoji-mart'; import { Emoji } from 'emoji-mart';
import React from 'react'; import React from 'react';

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
let accessToken = '';
export function setAccessToken(newToken: string) {
console.log(newToken);
accessToken = newToken;
}
export function getAccessToken() {
return accessToken;
}
export async function getNewToken() {
return fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(x => {
return x.json();
});
}

View File

@ -1,5 +0,0 @@
import { PermissionObjectType, PermissionLevel } from 'App/context';
export default function userCan(level: PermissionLevel, objectType: PermissionObjectType) {
return false;
}

View File

@ -59,11 +59,6 @@ type User = TaskUser & {
owned: RelatedList; owned: RelatedList;
}; };
type RefreshTokenResponse = {
accessToken: string;
setup?: null | { confirmToken: string };
};
type LoginFormData = { type LoginFormData = {
username: string; username: string;
password: string; password: string;

View File

@ -5397,10 +5397,10 @@ emojis-list@^3.0.0:
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
emoticon@^3.2.0: emoticon@^4.0.0:
version "3.2.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-3.2.0.tgz#c008ca7d7620fac742fe1bf4af8ff8fed154ae7f" resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-4.0.0.tgz#827be62e6fee2a47517187992358adc5297ca320"
integrity sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg== integrity sha512-Of6PNiAQGHhprBEootT77SdsynrVD97v1nzpiJ+FB2XCyvZmwdVI+hlpUduKWb2+7uVOcE1Myh3QAVB8vEBXvw==
encodeurl@~1.0.2: encodeurl@~1.0.2:
version "1.0.2" version "1.0.2"

6
go.mod
View File

@ -3,11 +3,11 @@ module github.com/jordanknott/taskcafe
go 1.13 go 1.13
require ( require (
github.com/99designs/gqlgen v0.11.3 github.com/99designs/gqlgen v0.13.0
github.com/RichardKnop/machinery v1.9.1 github.com/RichardKnop/machinery v1.9.1
github.com/brianvoe/gofakeit/v5 v5.11.2 github.com/brianvoe/gofakeit/v5 v5.11.2
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/go-chi/chi v3.3.2+incompatible github.com/go-chi/chi v3.3.2+incompatible
github.com/go-chi/cors v1.2.0
github.com/golang-migrate/migrate/v4 v4.11.0 github.com/golang-migrate/migrate/v4 v4.11.0
github.com/google/uuid v1.1.1 github.com/google/uuid v1.1.1
github.com/jinzhu/now v1.1.1 github.com/jinzhu/now v1.1.1
@ -24,7 +24,7 @@ require (
github.com/spf13/cobra v1.0.0 github.com/spf13/cobra v1.0.0
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.4.0 github.com/spf13/viper v1.4.0
github.com/vektah/gqlparser/v2 v2.0.1 github.com/vektah/gqlparser/v2 v2.1.0
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1 gopkg.in/mail.v2 v2.3.1

19
go.sum
View File

@ -40,8 +40,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4= github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4= github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
@ -96,9 +96,6 @@ github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwj
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0= github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/brianvoe/gofakeit v1.2.0 h1:GGbzCqQx9ync4ObAUhRa3F/M73eL9VZL3X09WoTwphM=
github.com/brianvoe/gofakeit v3.18.0+incompatible h1:wDOmHc9DLG4nRjUVVaxA+CEglKOW72Y5+4WNxUIkjM8=
github.com/brianvoe/gofakeit v3.18.0+incompatible/go.mod h1:kfwdRA90vvNhPutZWfH7WPaDzUjz+CZFqG+rPkOjGOc=
github.com/brianvoe/gofakeit/v5 v5.11.2 h1:Ny5Nsf4z2023ZvYP8ujW8p5B1t5sxhdFaQ/0IYXbeSA= github.com/brianvoe/gofakeit/v5 v5.11.2 h1:Ny5Nsf4z2023ZvYP8ujW8p5B1t5sxhdFaQ/0IYXbeSA=
github.com/brianvoe/gofakeit/v5 v5.11.2/go.mod h1:/ZENnKqX+XrN8SORLe/fu5lZDIo1tuPncWuRD+eyhSI= github.com/brianvoe/gofakeit/v5 v5.11.2/go.mod h1:/ZENnKqX+XrN8SORLe/fu5lZDIo1tuPncWuRD+eyhSI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@ -125,6 +122,7 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk= github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
@ -166,6 +164,8 @@ github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ= github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.2.0 h1:tV1g1XENQ8ku4Bq3K9ub2AtgG+p16SmzeMSGTwrOKdE=
github.com/go-chi/cors v1.2.0/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@ -293,10 +293,10 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
@ -550,6 +550,7 @@ github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA=
github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k= github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
@ -559,8 +560,8 @@ github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe h1:9YnI5plmy
github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe/go.mod h1:JTFJA/t820uFDoyPpErFQ3rb3amdZoPtxcKervG0OE4= github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe/go.mod h1:JTFJA/t820uFDoyPpErFQ3rb3amdZoPtxcKervG0OE4=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o= github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms= github.com/vektah/gqlparser/v2 v2.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk=
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=

View File

@ -1,117 +0,0 @@
package auth
import (
"time"
"github.com/dgrijalva/jwt-go"
log "github.com/sirupsen/logrus"
)
// RestrictedMode is used restrict JWT access to just the install route
type RestrictedMode string
const (
// Unrestricted is the code to allow access to all routes
Unrestricted RestrictedMode = "unrestricted"
// InstallOnly is the code to restrict access ONLY to install route
InstallOnly = "install_only"
)
// Role is the role code for the user
type Role string
const (
// RoleAdmin is the code for the admin role
RoleAdmin Role = "admin"
// RoleMember is the code for the member role
RoleMember Role = "member"
)
// AccessTokenClaims is the claims the access JWT token contains
type AccessTokenClaims struct {
UserID string `json:"userId"`
Restricted RestrictedMode `json:"restricted"`
OrgRole Role `json:"orgRole"`
jwt.StandardClaims
}
// ErrExpiredToken is the error returned if the token has expired
type ErrExpiredToken struct{}
// Error returns the error message for ErrExpiredToken
func (r *ErrExpiredToken) Error() string {
return "token is expired"
}
// ErrMalformedToken is the error returned if the token has malformed
type ErrMalformedToken struct{}
// Error returns the error message for ErrMalformedToken
func (r *ErrMalformedToken) Error() string {
return "token is malformed"
}
// NewAccessToken generates a new JWT access token with the correct claims
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string, jwtKey []byte, expirationTime time.Duration) (string, error) {
role := RoleMember
if orgRole == "admin" {
role = RoleAdmin
}
accessExpirationTime := time.Now().Add(expirationTime)
accessClaims := &AccessTokenClaims{
UserID: userID,
Restricted: restrictedMode,
OrgRole: role,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString(jwtKey)
if err != nil {
return "", err
}
return accessTokenString, nil
}
// NewAccessTokenCustomExpiration creates an access token with a custom duration
func NewAccessTokenCustomExpiration(userID string, dur time.Duration, jwtKey []byte) (string, error) {
accessExpirationTime := time.Now().Add(dur)
accessClaims := &AccessTokenClaims{
UserID: userID,
Restricted: Unrestricted,
OrgRole: RoleMember,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString(jwtKey)
if err != nil {
return "", err
}
return accessTokenString, nil
}
// ValidateAccessToken validates a JWT access token and returns the contained claims or an error if it's invalid
func ValidateAccessToken(accessTokenString string, jwtKey []byte) (AccessTokenClaims, error) {
accessClaims := &AccessTokenClaims{}
accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
if accessToken.Valid {
log.WithFields(log.Fields{
"token": accessTokenString,
"timeToExpire": time.Unix(accessClaims.ExpiresAt, 0),
}).Debug("token is valid")
return *accessClaims, nil
}
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&(jwt.ValidationErrorMalformed|jwt.ValidationErrorSignatureInvalid) != 0 {
return AccessTokenClaims{}, &ErrMalformedToken{}
} else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 {
return AccessTokenClaims{}, &ErrExpiredToken{}
}
}
return AccessTokenClaims{}, err
}

View File

@ -1,56 +0,0 @@
package auth
import (
"testing"
"time"
"github.com/dgrijalva/jwt-go"
)
// Override time value for jwt tests. Restore default value after.
func at(t time.Time, f func()) {
jwt.TimeFunc = func() time.Time {
return t
}
f()
jwt.TimeFunc = time.Now
}
func TestAuth_ValidateAccessToken(t *testing.T) {
expectedToken := AccessTokenClaims{
UserID: "1234",
Restricted: "unrestricted",
OrgRole: "member",
StandardClaims: jwt.StandardClaims{ExpiresAt: 1000},
}
// jwt with the claims of expectedToken signed by secretKey
jwtString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0IiwicmVzdHJpY3RlZCI6InVucmVzdHJpY3RlZCIsIm9yZ1JvbGUiOiJtZW1iZXIiLCJleHAiOjEwMDB9.Zc4mrnogDccYffA7dWogdWsZMELftQluh2X5xDyzOpA"
secretKey := []byte("secret")
// Check that decrypt failure is detected
token, err := ValidateAccessToken(jwtString, []byte("incorrectSecret"))
if err == nil {
t.Errorf("[IncorrectKey] Expected an error when validating a token with the incorrect key, instead got token %v", token)
} else if _, ok := err.(*ErrMalformedToken); !ok {
t.Errorf("[IncorrectKey] Expected an ErrMalformedToken error when validating a token with the incorrect key, instead got error %T:%v", err, err)
}
// Check that token expiration check works
token, err = ValidateAccessToken(jwtString, secretKey)
if err == nil {
t.Errorf("[TokenExpired] Expected an error when validating an expired token, instead got token %v", token)
} else if _, ok := err.(*ErrExpiredToken); !ok {
t.Errorf("[TokenExpired] Expected an ErrExpiredToken error when validating an expired token, instead got error %T:%v", err, err)
}
// Check that token validation works with a valid token
// Set the time to be valid for the token expiration
at(time.Unix(500, 0), func() {
token, err = ValidateAccessToken(jwtString, secretKey)
if err != nil {
t.Errorf("[TokenValid] Expected no errors when validating token, instead got err %v", err)
} else if token != expectedToken {
t.Errorf("[TokenValid] Expected token with claims %v but instead had claims %v", expectedToken, token)
}
})
}

View File

@ -86,6 +86,6 @@ func Execute() {
viper.SetDefault("queue.store", "memcache://localhost:11211") viper.SetDefault("queue.store", "memcache://localhost:11211")
rootCmd.SetVersionTemplate(VersionTemplate()) rootCmd.SetVersionTemplate(VersionTemplate())
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newTokenCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd()) rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
rootCmd.Execute() rootCmd.Execute()
} }

View File

@ -1,35 +0,0 @@
package commands
import (
"errors"
"fmt"
"strings"
"time"
"github.com/jordanknott/taskcafe/internal/auth"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func newTokenCmd() *cobra.Command {
return &cobra.Command{
Use: "token",
Short: "Create a long lived JWT token for dev purposes",
Long: "Create a long lived JWT token for dev purposes",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
secret := viper.GetString("server.secret")
if strings.TrimSpace(secret) == "" {
return errors.New("server.secret must be set (TASKCAFE_SERVER_SECRET)")
}
token, err := auth.NewAccessTokenCustomExpiration(args[0], time.Hour*24, []byte(secret))
if err != nil {
log.WithError(err).Error("issue while creating access token")
return err
}
fmt.Println(token)
return nil
},
}
}

View File

@ -10,6 +10,13 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type AuthToken struct {
TokenID uuid.UUID `json:"token_id"`
UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type LabelColor struct { type LabelColor struct {
LabelColorID uuid.UUID `json:"label_color_id"` LabelColorID uuid.UUID `json:"label_color_id"`
ColorHex string `json:"color_hex"` ColorHex string `json:"color_hex"`
@ -74,13 +81,6 @@ type ProjectMemberInvited struct {
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"` UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
} }
type RefreshToken struct {
TokenID uuid.UUID `json:"token_id"`
UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type Role struct { type Role struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`

View File

@ -9,6 +9,7 @@ import (
) )
type Querier interface { type Querier interface {
CreateAuthToken(ctx context.Context, arg CreateAuthTokenParams) (AuthToken, error)
CreateConfirmToken(ctx context.Context, email string) (UserAccountConfirmToken, error) CreateConfirmToken(ctx context.Context, email string) (UserAccountConfirmToken, error)
CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error) CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error)
CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error) CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error)
@ -20,7 +21,6 @@ type Querier interface {
CreatePersonalProjectLink(ctx context.Context, arg CreatePersonalProjectLinkParams) (PersonalProject, error) CreatePersonalProjectLink(ctx context.Context, arg CreatePersonalProjectLinkParams) (PersonalProject, error)
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error) CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error) CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error) CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
CreateTaskActivity(ctx context.Context, arg CreateTaskActivityParams) (TaskActivity, error) CreateTaskActivity(ctx context.Context, arg CreateTaskActivityParams) (TaskActivity, error)
@ -35,6 +35,8 @@ type Querier interface {
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error) CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, error) CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, error)
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
DeleteAuthTokenByID(ctx context.Context, tokenID uuid.UUID) error
DeleteAuthTokenByUserID(ctx context.Context, userID uuid.UUID) error
DeleteConfirmTokenForEmail(ctx context.Context, email string) error DeleteConfirmTokenForEmail(ctx context.Context, email string) error
DeleteExpiredTokens(ctx context.Context) error DeleteExpiredTokens(ctx context.Context) error
DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error
@ -43,8 +45,6 @@ type Querier interface {
DeleteProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) error DeleteProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) error
DeleteProjectMember(ctx context.Context, arg DeleteProjectMemberParams) error DeleteProjectMember(ctx context.Context, arg DeleteProjectMemberParams) error
DeleteProjectMemberInvitedForEmail(ctx context.Context, email string) error DeleteProjectMemberInvitedForEmail(ctx context.Context, email string) error
DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error
DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error
DeleteTaskAssignedByID(ctx context.Context, arg DeleteTaskAssignedByIDParams) (TaskAssigned, error) DeleteTaskAssignedByID(ctx context.Context, arg DeleteTaskAssignedByIDParams) (TaskAssigned, error)
DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error
DeleteTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) error DeleteTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) error
@ -71,6 +71,7 @@ type Querier interface {
GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error) GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error)
GetAssignedTasksDueDateForUserID(ctx context.Context, arg GetAssignedTasksDueDateForUserIDParams) ([]Task, error) GetAssignedTasksDueDateForUserID(ctx context.Context, arg GetAssignedTasksDueDateForUserIDParams) ([]Task, error)
GetAssignedTasksProjectForUserID(ctx context.Context, arg GetAssignedTasksProjectForUserIDParams) ([]Task, error) GetAssignedTasksProjectForUserID(ctx context.Context, arg GetAssignedTasksProjectForUserIDParams) ([]Task, error)
GetAuthTokenByID(ctx context.Context, tokenID uuid.UUID) (AuthToken, error)
GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error) GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error)
GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error) GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error)
GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error) GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error)
@ -100,7 +101,6 @@ type Querier interface {
GetProjectRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetProjectRolesForUserIDRow, error) GetProjectRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetProjectRolesForUserIDRow, error)
GetProjectsForInvitedMember(ctx context.Context, email string) ([]uuid.UUID, error) GetProjectsForInvitedMember(ctx context.Context, email string) ([]uuid.UUID, error)
GetRecentlyAssignedTaskForUserID(ctx context.Context, arg GetRecentlyAssignedTaskForUserIDParams) ([]Task, error) GetRecentlyAssignedTaskForUserID(ctx context.Context, arg GetRecentlyAssignedTaskForUserIDParams) ([]Task, error)
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error) GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error) GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error) GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error)

View File

@ -1,14 +1,14 @@
-- name: GetRefreshTokenByID :one -- name: GetAuthTokenByID :one
SELECT * FROM refresh_token WHERE token_id = $1; SELECT * FROM auth_token WHERE token_id = $1;
-- name: CreateRefreshToken :one -- name: CreateAuthToken :one
INSERT INTO refresh_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING *; INSERT INTO auth_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING *;
-- name: DeleteRefreshTokenByID :exec -- name: DeleteAuthTokenByID :exec
DELETE FROM refresh_token WHERE token_id = $1; DELETE FROM auth_token WHERE token_id = $1;
-- name: DeleteRefreshTokenByUserID :exec -- name: DeleteAuthTokenByUserID :exec
DELETE FROM refresh_token WHERE user_id = $1; DELETE FROM auth_token WHERE user_id = $1;
-- name: DeleteExpiredTokens :exec -- name: DeleteExpiredTokens :exec
DELETE FROM refresh_token WHERE expires_at <= NOW(); DELETE FROM auth_token WHERE expires_at <= NOW();

View File

@ -10,19 +10,19 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
const createRefreshToken = `-- name: CreateRefreshToken :one const createAuthToken = `-- name: CreateAuthToken :one
INSERT INTO refresh_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING token_id, user_id, created_at, expires_at INSERT INTO auth_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING token_id, user_id, created_at, expires_at
` `
type CreateRefreshTokenParams struct { type CreateAuthTokenParams struct {
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
} }
func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) { func (q *Queries) CreateAuthToken(ctx context.Context, arg CreateAuthTokenParams) (AuthToken, error) {
row := q.db.QueryRowContext(ctx, createRefreshToken, arg.UserID, arg.CreatedAt, arg.ExpiresAt) row := q.db.QueryRowContext(ctx, createAuthToken, arg.UserID, arg.CreatedAt, arg.ExpiresAt)
var i RefreshToken var i AuthToken
err := row.Scan( err := row.Scan(
&i.TokenID, &i.TokenID,
&i.UserID, &i.UserID,
@ -32,8 +32,26 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken
return i, err return i, err
} }
const deleteAuthTokenByID = `-- name: DeleteAuthTokenByID :exec
DELETE FROM auth_token WHERE token_id = $1
`
func (q *Queries) DeleteAuthTokenByID(ctx context.Context, tokenID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteAuthTokenByID, tokenID)
return err
}
const deleteAuthTokenByUserID = `-- name: DeleteAuthTokenByUserID :exec
DELETE FROM auth_token WHERE user_id = $1
`
func (q *Queries) DeleteAuthTokenByUserID(ctx context.Context, userID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteAuthTokenByUserID, userID)
return err
}
const deleteExpiredTokens = `-- name: DeleteExpiredTokens :exec const deleteExpiredTokens = `-- name: DeleteExpiredTokens :exec
DELETE FROM refresh_token WHERE expires_at <= NOW() DELETE FROM auth_token WHERE expires_at <= NOW()
` `
func (q *Queries) DeleteExpiredTokens(ctx context.Context) error { func (q *Queries) DeleteExpiredTokens(ctx context.Context) error {
@ -41,31 +59,13 @@ func (q *Queries) DeleteExpiredTokens(ctx context.Context) error {
return err return err
} }
const deleteRefreshTokenByID = `-- name: DeleteRefreshTokenByID :exec const getAuthTokenByID = `-- name: GetAuthTokenByID :one
DELETE FROM refresh_token WHERE token_id = $1 SELECT token_id, user_id, created_at, expires_at FROM auth_token WHERE token_id = $1
` `
func (q *Queries) DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error { func (q *Queries) GetAuthTokenByID(ctx context.Context, tokenID uuid.UUID) (AuthToken, error) {
_, err := q.db.ExecContext(ctx, deleteRefreshTokenByID, tokenID) row := q.db.QueryRowContext(ctx, getAuthTokenByID, tokenID)
return err var i AuthToken
}
const deleteRefreshTokenByUserID = `-- name: DeleteRefreshTokenByUserID :exec
DELETE FROM refresh_token WHERE user_id = $1
`
func (q *Queries) DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteRefreshTokenByUserID, userID)
return err
}
const getRefreshTokenByID = `-- name: GetRefreshTokenByID :one
SELECT token_id, user_id, created_at, expires_at FROM refresh_token WHERE token_id = $1
`
func (q *Queries) GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error) {
row := q.db.QueryRowContext(ctx, getRefreshTokenByID, tokenID)
var i RefreshToken
err := row.Scan( err := row.Scan(
&i.TokenID, &i.TokenID,
&i.UserID, &i.UserID,

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,6 @@ import (
"github.com/99designs/gqlgen/graphql/handler/transport" "github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground" "github.com/99designs/gqlgen/graphql/playground"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger" "github.com/jordanknott/taskcafe/internal/logger"
"github.com/jordanknott/taskcafe/internal/utils" "github.com/jordanknott/taskcafe/internal/utils"
@ -34,6 +33,8 @@ func NewHandler(repo db.Repository, emailConfig utils.EmailConfig) http.Handler
}, },
} }
c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) { c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) {
/*
TODO: add permission check
role, ok := GetUserRole(ctx) role, ok := GetUserRole(ctx)
if !ok { if !ok {
return nil, errors.New("user ID is missing") return nil, errors.New("user ID is missing")
@ -43,6 +44,7 @@ func NewHandler(repo db.Repository, emailConfig utils.EmailConfig) http.Handler
} else if level == ActionLevelOrg { } else if level == ActionLevelOrg {
return nil, errors.New("must be an org admin") return nil, errors.New("must be an org admin")
} }
*/
var subjectID uuid.UUID var subjectID uuid.UUID
in := graphql.GetFieldContext(ctx).Args["input"] in := graphql.GetFieldContext(ctx).Args["input"]
@ -76,7 +78,7 @@ func NewHandler(repo db.Repository, emailConfig utils.EmailConfig) http.Handler
// TODO: add config setting to disable personal projects // TODO: add config setting to disable personal projects
return next(ctx) return next(ctx)
} }
subjectID, ok = subjectField.Interface().(uuid.UUID) subjectID, ok := subjectField.Interface().(uuid.UUID)
if !ok { if !ok {
logger.New(ctx).Error("error while casting subject UUID") logger.New(ctx).Error("error while casting subject UUID")
return nil, errors.New("error while casting subject uuid") return nil, errors.New("error while casting subject uuid")
@ -190,23 +192,10 @@ func GetUserID(ctx context.Context) (uuid.UUID, bool) {
return userID, ok return userID, ok
} }
// GetUserRole retrieves the user role out of a context
func GetUserRole(ctx context.Context) (auth.Role, bool) {
role, ok := ctx.Value(utils.OrgRoleKey).(auth.Role)
return role, ok
}
// GetUser retrieves both the user id & user role out of a context // GetUser retrieves both the user id & user role out of a context
func GetUser(ctx context.Context) (uuid.UUID, auth.Role, bool) { func GetUser(ctx context.Context) (uuid.UUID, bool) {
userID, userOK := GetUserID(ctx) userID, userOK := GetUserID(ctx)
role, roleOK := GetUserRole(ctx) return userID, userOK
return userID, role, userOK && roleOK
}
// GetRestrictedMode retrieves the restricted mode code out of a context
func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) {
restricted, ok := ctx.Value(utils.RestrictedModeKey).(auth.RestrictedMode)
return restricted, ok
} }
// GetProjectRoles retrieves the team & project role for the given project ID // GetProjectRoles retrieves the team & project role for the given project ID

View File

@ -255,6 +255,7 @@ type LogoutUser struct {
type MePayload struct { type MePayload struct {
User *db.UserAccount `json:"user"` User *db.UserAccount `json:"user"`
Organization *RoleCode `json:"organization"`
TeamRoles []TeamRole `json:"teamRoles"` TeamRoles []TeamRole `json:"teamRoles"`
ProjectRoles []ProjectRole `json:"projectRoles"` ProjectRoles []ProjectRole `json:"projectRoles"`
} }
@ -312,10 +313,6 @@ type NewProjectLabel struct {
Name *string `json:"name"` Name *string `json:"name"`
} }
type NewRefreshToken struct {
UserID uuid.UUID `json:"userID"`
}
type NewTask struct { type NewTask struct {
TaskGroupID uuid.UUID `json:"taskGroupID"` TaskGroupID uuid.UUID `json:"taskGroupID"`
Name string `json:"name"` Name string `json:"name"`
@ -382,6 +379,12 @@ type ProfileIcon struct {
BgColor *string `json:"bgColor"` BgColor *string `json:"bgColor"`
} }
type ProjectPermission struct {
Team RoleCode `json:"team"`
Project RoleCode `json:"project"`
Org RoleCode `json:"org"`
}
type ProjectRole struct { type ProjectRole struct {
ProjectID uuid.UUID `json:"projectID"` ProjectID uuid.UUID `json:"projectID"`
RoleCode RoleCode `json:"roleCode"` RoleCode RoleCode `json:"roleCode"`
@ -435,6 +438,11 @@ type TaskPositionUpdate struct {
Position float64 `json:"position"` Position float64 `json:"position"`
} }
type TeamPermission struct {
Team RoleCode `json:"team"`
Org RoleCode `json:"org"`
}
type TeamRole struct { type TeamRole struct {
TeamID uuid.UUID `json:"teamID"` TeamID uuid.UUID `json:"teamID"`
RoleCode RoleCode `json:"roleCode"` RoleCode RoleCode `json:"roleCode"`

View File

@ -50,13 +50,6 @@ type Member {
member: MemberList! member: MemberList!
} }
type RefreshToken {
id: ID!
userId: UUID!
expiresAt: Time!
createdAt: Time!
}
type Role { type Role {
code: String! code: String!
name: String! name: String!
@ -97,6 +90,7 @@ type Team {
id: ID! id: ID!
createdAt: Time! createdAt: Time!
name: String! name: String!
permission: TeamPermission!
members: [Member!]! members: [Member!]!
} }
@ -106,6 +100,17 @@ type InvitedMember {
invitedOn: Time! invitedOn: Time!
} }
type TeamPermission {
team: RoleCode!
org: RoleCode!
}
type ProjectPermission {
team: RoleCode!
project: RoleCode!
org: RoleCode!
}
type Project { type Project {
id: ID! id: ID!
createdAt: Time! createdAt: Time!
@ -114,6 +119,7 @@ type Project {
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
members: [Member!]! members: [Member!]!
invitedMembers: [InvitedMember!]! invitedMembers: [InvitedMember!]!
permission: ProjectPermission!
labels: [ProjectLabel!]! labels: [ProjectLabel!]!
} }
@ -314,6 +320,7 @@ type ProjectRole {
type MePayload { type MePayload {
user: UserAccount! user: UserAccount!
organization: RoleCode
teamRoles: [TeamRole!]! teamRoles: [TeamRole!]!
projectRoles: [ProjectRole!]! projectRoles: [ProjectRole!]!
} }
@ -881,7 +888,6 @@ type UpdateTeamMemberRolePayload {
} }
extend type Mutation { extend type Mutation {
createRefreshToken(input: NewRefreshToken!): RefreshToken!
createUserAccount(input: NewUserAccount!): createUserAccount(input: NewUserAccount!):
UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG) UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteUserAccount(input: DeleteUserAccount!): deleteUserAccount(input: DeleteUserAccount!):
@ -954,10 +960,6 @@ type UpdateUserRolePayload {
user: UserAccount! user: UserAccount!
} }
input NewRefreshToken {
userID: UUID!
}
input NewUserAccount { input NewUserAccount {
username: String! username: String!
email: String! email: String!

View File

@ -13,7 +13,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jinzhu/now" "github.com/jinzhu/now"
"github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger" "github.com/jordanknott/taskcafe/internal/logger"
"github.com/jordanknott/taskcafe/internal/utils" "github.com/jordanknott/taskcafe/internal/utils"
@ -864,11 +863,12 @@ func (r *mutationResolver) DeleteTeam(ctx context.Context, input DeleteTeam) (*D
} }
func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*db.Team, error) { func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*db.Team, error) {
_, role, ok := GetUser(ctx) _, ok := GetUser(ctx)
if !ok { if !ok {
return &db.Team{}, nil return &db.Team{}, nil
} }
if role == auth.RoleAdmin { // if role == auth.RoleAdmin { // TODO: add permision check
if true {
createdAt := time.Now().UTC() createdAt := time.Now().UTC()
team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name}) team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name})
return &team, err return &team, err
@ -944,20 +944,13 @@ func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTea
return &DeleteTeamMemberPayload{TeamID: input.TeamID, UserID: input.UserID}, err return &DeleteTeamMemberPayload{TeamID: input.TeamID, UserID: input.UserID}, err
} }
func (r *mutationResolver) CreateRefreshToken(ctx context.Context, input NewRefreshToken) (*db.RefreshToken, error) {
userID := uuid.MustParse("0183d9ab-d0ed-4c9b-a3df-77a0cdd93dca")
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshToken, err := r.Repository.CreateRefreshToken(ctx, db.CreateRefreshTokenParams{userID, refreshCreatedAt, refreshExpiresAt})
return &refreshToken, err
}
func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*db.UserAccount, error) { func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*db.UserAccount, error) {
_, role, ok := GetUser(ctx) _, ok := GetUser(ctx)
if !ok { if !ok {
return &db.UserAccount{}, nil return &db.UserAccount{}, nil
} }
if role != auth.RoleAdmin { // if role != auth.RoleAdmin { TODO: add permsion check
if true {
return &db.UserAccount{}, &gqlerror.Error{ return &db.UserAccount{}, &gqlerror.Error{
Message: "Must be an organization admin", Message: "Must be an organization admin",
Extensions: map[string]interface{}{ Extensions: map[string]interface{}{
@ -984,11 +977,12 @@ func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserA
} }
func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUserAccount) (*DeleteUserAccountPayload, error) { func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUserAccount) (*DeleteUserAccountPayload, error) {
_, role, ok := GetUser(ctx) _, ok := GetUser(ctx)
if !ok { if !ok {
return &DeleteUserAccountPayload{Ok: false}, nil return &DeleteUserAccountPayload{Ok: false}, nil
} }
if role != auth.RoleAdmin { // if role != auth.RoleAdmin { TODO: add permision check
if true {
return &DeleteUserAccountPayload{Ok: false}, &gqlerror.Error{ return &DeleteUserAccountPayload{Ok: false}, &gqlerror.Error{
Message: "User not found", Message: "User not found",
Extensions: map[string]interface{}{ Extensions: map[string]interface{}{
@ -1030,7 +1024,7 @@ func (r *mutationResolver) DeleteInvitedUserAccount(ctx context.Context, input D
} }
func (r *mutationResolver) LogoutUser(ctx context.Context, input LogoutUser) (bool, error) { func (r *mutationResolver) LogoutUser(ctx context.Context, input LogoutUser) (bool, error) {
err := r.Repository.DeleteRefreshTokenByUserID(ctx, input.UserID) err := r.Repository.DeleteAuthTokenByUserID(ctx, input.UserID)
return true, err return true, err
} }
@ -1059,11 +1053,12 @@ func (r *mutationResolver) UpdateUserPassword(ctx context.Context, input UpdateU
} }
func (r *mutationResolver) UpdateUserRole(ctx context.Context, input UpdateUserRole) (*UpdateUserRolePayload, error) { func (r *mutationResolver) UpdateUserRole(ctx context.Context, input UpdateUserRole) (*UpdateUserRolePayload, error) {
_, role, ok := GetUser(ctx) _, ok := GetUser(ctx)
if !ok { if !ok {
return &UpdateUserRolePayload{}, nil return &UpdateUserRolePayload{}, nil
} }
if role != auth.RoleAdmin { // if role != auth.RoleAdmin { TODO: add permision check
if true {
return &UpdateUserRolePayload{}, &gqlerror.Error{ return &UpdateUserRolePayload{}, &gqlerror.Error{
Message: "User not found", Message: "User not found",
Extensions: map[string]interface{}{ Extensions: map[string]interface{}{
@ -1211,6 +1206,10 @@ func (r *projectResolver) InvitedMembers(ctx context.Context, obj *db.Project) (
return invited, err return invited, err
} }
func (r *projectResolver) Permission(ctx context.Context, obj *db.Project) (*ProjectPermission, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *projectResolver) Labels(ctx context.Context, obj *db.Project) ([]db.ProjectLabel, error) { func (r *projectResolver) Labels(ctx context.Context, obj *db.Project) ([]db.ProjectLabel, error) {
labels, err := r.Repository.GetProjectLabelsForProject(ctx, obj.ProjectID) labels, err := r.Repository.GetProjectLabelsForProject(ctx, obj.ProjectID)
return labels, err return labels, err
@ -1296,7 +1295,7 @@ func (r *queryResolver) FindTask(ctx context.Context, input FindTask) (*db.Task,
} }
func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]db.Project, error) { func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]db.Project, error) {
userID, orgRole, ok := GetUser(ctx) userID, ok := GetUser(ctx)
if !ok { if !ok {
logger.New(ctx).Info("user id was not found from middleware") logger.New(ctx).Info("user id was not found from middleware")
return []db.Project{}, nil return []db.Project{}, nil
@ -1309,11 +1308,14 @@ func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]
var teams []db.Team var teams []db.Team
var err error var err error
/* TODO: add permsion check
if orgRole == "admin" { if orgRole == "admin" {
teams, err = r.Repository.GetAllTeams(ctx) teams, err = r.Repository.GetAllTeams(ctx)
} else { } else {
teams, err = r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID) teams, err = r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
} }
*/
teams, err = r.Repository.GetAllTeams(ctx)
projects := make(map[string]db.Project) projects := make(map[string]db.Project)
for _, team := range teams { for _, team := range teams {
@ -1359,15 +1361,18 @@ func (r *queryResolver) FindTeam(ctx context.Context, input FindTeam) (*db.Team,
} }
func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) { func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
userID, orgRole, ok := GetUser(ctx) userID, ok := GetUser(ctx)
if !ok { if !ok {
logger.New(ctx).Error("userID or org role does not exist") logger.New(ctx).Error("userID or org role does not exist")
return []db.Team{}, errors.New("internal error") return []db.Team{}, errors.New("internal error")
} }
if orgRole == "admin" {
/*
TODO: add permision check
if orgRole == "admin" {
return r.Repository.GetAllTeams(ctx) return r.Repository.GetAllTeams(ctx)
} }
*/
teams := make(map[string]db.Team) teams := make(map[string]db.Team)
adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID) adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
@ -1596,10 +1601,6 @@ func (r *queryResolver) SearchMembers(ctx context.Context, input MemberSearchFil
return results, nil return results, nil
} }
func (r *refreshTokenResolver) ID(ctx context.Context, obj *db.RefreshToken) (uuid.UUID, error) {
return obj.TokenID, nil
}
func (r *taskResolver) ID(ctx context.Context, obj *db.Task) (uuid.UUID, error) { func (r *taskResolver) ID(ctx context.Context, obj *db.Task) (uuid.UUID, error) {
return obj.TaskID, nil return obj.TaskID, nil
} }
@ -1848,6 +1849,10 @@ func (r *teamResolver) ID(ctx context.Context, obj *db.Team) (uuid.UUID, error)
return obj.TeamID, nil return obj.TeamID, nil
} }
func (r *teamResolver) Permission(ctx context.Context, obj *db.Team) (*TeamPermission, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, error) { func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, error) {
members := []Member{} members := []Member{}
@ -1966,9 +1971,6 @@ func (r *Resolver) ProjectLabel() ProjectLabelResolver { return &projectLabelRes
// Query returns QueryResolver implementation. // Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
// RefreshToken returns RefreshTokenResolver implementation.
func (r *Resolver) RefreshToken() RefreshTokenResolver { return &refreshTokenResolver{r} }
// Task returns TaskResolver implementation. // Task returns TaskResolver implementation.
func (r *Resolver) Task() TaskResolver { return &taskResolver{r} } func (r *Resolver) Task() TaskResolver { return &taskResolver{r} }
@ -2005,7 +2007,6 @@ type organizationResolver struct{ *Resolver }
type projectResolver struct{ *Resolver } type projectResolver struct{ *Resolver }
type projectLabelResolver struct{ *Resolver } type projectLabelResolver struct{ *Resolver }
type queryResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
type refreshTokenResolver struct{ *Resolver }
type taskResolver struct{ *Resolver } type taskResolver struct{ *Resolver }
type taskActivityResolver struct{ *Resolver } type taskActivityResolver struct{ *Resolver }
type taskChecklistResolver struct{ *Resolver } type taskChecklistResolver struct{ *Resolver }

View File

@ -50,13 +50,6 @@ type Member {
member: MemberList! member: MemberList!
} }
type RefreshToken {
id: ID!
userId: UUID!
expiresAt: Time!
createdAt: Time!
}
type Role { type Role {
code: String! code: String!
name: String! name: String!
@ -97,6 +90,7 @@ type Team {
id: ID! id: ID!
createdAt: Time! createdAt: Time!
name: String! name: String!
permission: TeamPermission!
members: [Member!]! members: [Member!]!
} }
@ -106,6 +100,17 @@ type InvitedMember {
invitedOn: Time! invitedOn: Time!
} }
type TeamPermission {
team: RoleCode!
org: RoleCode!
}
type ProjectPermission {
team: RoleCode!
project: RoleCode!
org: RoleCode!
}
type Project { type Project {
id: ID! id: ID!
createdAt: Time! createdAt: Time!
@ -114,6 +119,7 @@ type Project {
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
members: [Member!]! members: [Member!]!
invitedMembers: [InvitedMember!]! invitedMembers: [InvitedMember!]!
permission: ProjectPermission!
labels: [ProjectLabel!]! labels: [ProjectLabel!]!
} }

View File

@ -90,6 +90,7 @@ type ProjectRole {
type MePayload { type MePayload {
user: UserAccount! user: UserAccount!
organization: RoleCode
teamRoles: [TeamRole!]! teamRoles: [TeamRole!]!
projectRoles: [ProjectRole!]! projectRoles: [ProjectRole!]!
} }

View File

@ -1,5 +1,4 @@
extend type Mutation { extend type Mutation {
createRefreshToken(input: NewRefreshToken!): RefreshToken!
createUserAccount(input: NewUserAccount!): createUserAccount(input: NewUserAccount!):
UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG) UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteUserAccount(input: DeleteUserAccount!): deleteUserAccount(input: DeleteUserAccount!):
@ -72,10 +71,6 @@ type UpdateUserRolePayload {
user: UserAccount! user: UserAccount!
} }
input NewRefreshToken {
userID: UUID!
}
input NewUserAccount { input NewUserAccount {
username: String! username: String!
email: String! email: String!

View File

@ -8,7 +8,6 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -54,10 +53,15 @@ type Setup struct {
ConfirmToken string `json:"confirmToken"` ConfirmToken string `json:"confirmToken"`
} }
type ValidateAuthTokenResponse struct {
Valid bool `json:"valid"`
UserID string `json:"userID"`
}
// LoginResponseData is the response data for when a user logs in // LoginResponseData is the response data for when a user logs in
type LoginResponseData struct { type LoginResponseData struct {
AccessToken string `json:"accessToken"` UserID string `json:"userID"`
Setup bool `json:"setup"` Complete bool `json:"complete"`
} }
// LogoutResponseData is the response data for when a user logs out // LogoutResponseData is the response data for when a user logs out
@ -65,8 +69,8 @@ type LogoutResponseData struct {
Status string `json:"status"` Status string `json:"status"`
} }
// RefreshTokenResponseData is the response data for when an access token is refreshed // AuthTokenResponseData is the response data for when an access token is refreshed
type RefreshTokenResponseData struct { type AuthTokenResponseData struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
} }
@ -76,93 +80,9 @@ type AvatarUploadResponseData struct {
URL string `json:"url"` URL string `json:"url"`
} }
// RefreshTokenHandler handles when a user attempts to refresh token
func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
userExists, err := h.repo.HasAnyUser(r.Context())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.WithError(err).Error("issue while fetching if user accounts exist")
return
}
log.WithField("userExists", userExists).Info("checking if setup")
if !userExists {
w.Header().Set("Content-type", "application/json")
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: "", Setup: true})
return
}
c, err := r.Cookie("refreshToken")
if err != nil {
if err == http.ErrNoCookie {
log.Warn("no cookie")
w.WriteHeader(http.StatusBadRequest)
return
}
log.WithError(err).Error("unknown error")
w.WriteHeader(http.StatusBadRequest)
return
}
refreshTokenID := uuid.MustParse(c.Value)
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
if err != nil {
if err == sql.ErrNoRows {
log.WithError(err).WithFields(log.Fields{"refreshTokenID": refreshTokenID.String()}).Error("no tokens found")
w.WriteHeader(http.StatusBadRequest)
return
}
log.WithError(err).Error("token retrieve failure")
w.WriteHeader(http.StatusBadRequest)
return
}
user, err := h.repo.GetUserAccountByID(r.Context(), token.UserID)
if err != nil {
log.WithError(err).Error("user retrieve failure")
w.WriteHeader(http.StatusInternalServerError)
return
}
if !user.Active {
log.WithFields(log.Fields{
"username": user.Username,
}).Warn("attempt to refresh token with inactive user")
w.WriteHeader(http.StatusUnauthorized)
return
}
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{token.UserID, refreshCreatedAt, refreshExpiresAt})
err = h.repo.DeleteRefreshTokenByID(r.Context(), token.TokenID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
log.Info("here 1")
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
log.Info("here 2")
w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{
Name: "refreshToken",
Value: refreshTokenString.TokenID.String(),
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, Setup: false})
}
// LogoutHandler removes all refresh tokens to log out user // LogoutHandler removes all refresh tokens to log out user
func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("refreshToken") c, err := r.Cookie("authToken")
if err != nil { if err != nil {
if err == http.ErrNoCookie { if err == http.ErrNoCookie {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@ -172,7 +92,7 @@ func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request)
return return
} }
refreshTokenID := uuid.MustParse(c.Value) refreshTokenID := uuid.MustParse(c.Value)
err = h.repo.DeleteRefreshTokenByID(r.Context(), refreshTokenID) err = h.repo.DeleteAuthTokenByID(r.Context(), refreshTokenID)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
@ -216,87 +136,23 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
refreshCreatedAt := time.Now().UTC() authCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
w.Header().Set("Content-type", "application/json") w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "refreshToken", Name: "authToken",
Value: refreshTokenString.TokenID.String(), Value: authToken.TokenID.String(),
Expires: refreshExpiresAt, Expires: authExpiresAt,
Path: "/",
HttpOnly: true, HttpOnly: true,
}) })
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false}) json.NewEncoder(w).Encode(LoginResponseData{Complete: true, UserID: authToken.UserID.String()})
}
// TODO: remove
// InstallHandler creates first user on fresh install
func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request) {
if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok {
if restricted != auth.InstallOnly {
log.Warning("attempted to install without install only restriction")
w.WriteHeader(http.StatusBadRequest)
return
}
}
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
if err != sql.ErrNoRows {
log.WithError(err).Error("install handler called even though system is installed")
w.WriteHeader(http.StatusBadRequest)
return
}
var requestData InstallRequestData
err = json.NewDecoder(r.Body).Decode(&requestData)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
createdAt := time.Now().UTC()
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{
FullName: requestData.User.FullName,
Username: requestData.User.Username,
Initials: requestData.User.Initials,
Email: requestData.User.Email,
PasswordHash: string(hashedPwd),
CreatedAt: createdAt,
RoleCode: "admin",
})
_, err = h.repo.CreateSystemOption(r.Context(), db.CreateSystemOptionParams{Key: "is_installed", Value: sql.NullString{Valid: true, String: "true"}})
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
log.WithField("userID", user.UserID.String()).Info("creating install access token")
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
log.Info(accessTokenString)
w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{
Name: "refreshToken",
Value: refreshTokenString.TokenID.String(),
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
} }
func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) { func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) {
@ -382,23 +238,43 @@ func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) {
} }
refreshCreatedAt := time.Now().UTC() authCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) authToken, err := h.repo.CreateAuthToken(r.Context(), db.CreateAuthTokenParams{user.UserID, authCreatedAt, authExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return
} }
w.Header().Set("Content-type", "application/json") w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "refreshToken", Name: "authToken",
Value: refreshTokenString.TokenID.String(), Value: authToken.TokenID.String(),
Expires: refreshExpiresAt, Path: "/",
Expires: authExpiresAt,
HttpOnly: true, HttpOnly: true,
}) })
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false}) json.NewEncoder(w).Encode(LoginResponseData{Complete: true, UserID: authToken.UserID.String()})
}
func (h *TaskcafeHandler) ValidateAuthTokenHandler(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("authToken")
if err != nil {
if err == http.ErrNoCookie {
json.NewEncoder(w).Encode(ValidateAuthTokenResponse{Valid: false, UserID: ""})
return
}
log.WithError(err).Error("unknown error")
w.WriteHeader(http.StatusBadRequest)
return
}
authTokenID := uuid.MustParse(c.Value)
token, err := h.repo.GetAuthTokenByID(r.Context(), authTokenID)
if err != nil {
json.NewEncoder(w).Encode(ValidateAuthTokenResponse{Valid: false, UserID: ""})
} else {
json.NewEncoder(w).Encode(ValidateAuthTokenResponse{Valid: true, UserID: token.UserID.String()})
}
} }
func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
@ -425,6 +301,7 @@ func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.WithError(err).Error("error checking for active user") log.WithError(err).Error("error checking for active user")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return
} }
if !hasActiveUser { if !hasActiveUser {
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: true}) json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: true})
@ -469,7 +346,7 @@ func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
func (rs authResource) Routes(taskcafeHandler TaskcafeHandler) chi.Router { func (rs authResource) Routes(taskcafeHandler TaskcafeHandler) chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/login", taskcafeHandler.LoginHandler) r.Post("/login", taskcafeHandler.LoginHandler)
r.Post("/refresh_token", taskcafeHandler.RefreshTokenHandler)
r.Post("/logout", taskcafeHandler.LogoutHandler) r.Post("/logout", taskcafeHandler.LogoutHandler)
r.Post("/validate", taskcafeHandler.ValidateAuthTokenHandler)
return r return r
} }

View File

@ -3,33 +3,49 @@ package route
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/auth" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/utils" "github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header // AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header
type AuthenticationMiddleware struct { type AuthenticationMiddleware struct {
jwtKey []byte repo db.Repository
} }
// Middleware returns the middleware handler // Middleware returns the middleware handler
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler { func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New() requestID := uuid.New()
bearerTokenRaw := r.Header.Get("Authorization") foundToken := true
splitToken := strings.Split(bearerTokenRaw, "Bearer") tokenRaw := ""
if len(splitToken) != 2 { c, err := r.Cookie("authToken")
if err != nil {
if err == http.ErrNoCookie {
foundToken = false
} else {
log.WithError(err).Error("unknown error")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
return return
} }
accessTokenString := strings.TrimSpace(splitToken[1]) }
accessClaims, err := auth.ValidateAccessToken(accessTokenString, m.jwtKey) if !foundToken {
token := r.Header.Get("Authorization")
if token == "" {
log.WithError(err).Error("no auth token found in cookie or authorization header")
w.WriteHeader(http.StatusBadRequest)
return
}
tokenRaw = token
} else {
tokenRaw = c.Value
}
authTokenID := uuid.MustParse(tokenRaw)
token, err := m.repo.GetAuthTokenByID(r.Context(), authTokenID)
if err != nil { if err != nil {
if _, ok := err.(*auth.ErrExpiredToken); ok {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{ w.Write([]byte(`{
"data": {}, "data": {},
@ -43,25 +59,10 @@ func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
}`)) }`))
return return
} }
log.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
}
var userID uuid.UUID ctx := context.WithValue(r.Context(), utils.UserIDKey, token.UserID)
if accessClaims.Restricted == auth.InstallOnly { // ctx = context.WithValue(ctx, utils.RestrictedModeKey, accessClaims.Restricted)
userID = uuid.New() // ctx = context.WithValue(ctx, utils.OrgRoleKey, accessClaims.OrgRole)
} else {
userID, err = uuid.Parse(accessClaims.UserID)
if err != nil {
log.WithError(err).Error("middleware access token userID parse")
w.WriteHeader(http.StatusBadRequest)
return
}
}
ctx := context.WithValue(r.Context(), utils.UserIDKey, userID)
ctx = context.WithValue(ctx, utils.RestrictedModeKey, accessClaims.Restricted)
ctx = context.WithValue(ctx, utils.OrgRoleKey, accessClaims.OrgRole)
ctx = context.WithValue(ctx, utils.ReqIDKey, requestID) ctx = context.WithValue(ctx, utils.ReqIDKey, requestID)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))

View File

@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -80,6 +81,17 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityCon
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second)) r.Use(middleware.Timeout(60 * time.Second))
r.Use(cors.Handler(cors.Options{
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
AllowedOrigins: []string{"https://*", "http://*"},
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Cookie", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
repository := db.NewRepository(dbConnection) repository := db.NewRepository(dbConnection)
taskcafeHandler := TaskcafeHandler{*repository, securityConfig} taskcafeHandler := TaskcafeHandler{*repository, securityConfig}
@ -91,7 +103,7 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityCon
mux.Post("/auth/confirm", taskcafeHandler.ConfirmUser) mux.Post("/auth/confirm", taskcafeHandler.ConfirmUser)
mux.Post("/auth/register", taskcafeHandler.RegisterUser) mux.Post("/auth/register", taskcafeHandler.RegisterUser)
}) })
auth := AuthenticationMiddleware{securityConfig.Secret} auth := AuthenticationMiddleware{*repository}
r.Group(func(mux chi.Router) { r.Group(func(mux chi.Router) {
mux.Use(auth.Middleware) mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload) mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)

View File

@ -0,0 +1 @@
ALTER TABLE refresh_token RENAME TO auth_token;