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:
parent
3392b3345d
commit
229a53fa0a
@ -39,7 +39,7 @@
|
||||
"dayjs": "^1.9.1",
|
||||
"dompurify": "^2.2.6",
|
||||
"emoji-mart": "^3.0.0",
|
||||
"emoticon": "^3.2.0",
|
||||
"emoticon": "^4.0.0",
|
||||
"graphql": "^15.0.0",
|
||||
"graphql-tag": "^2.10.3",
|
||||
"history": "^4.10.1",
|
||||
|
@ -215,9 +215,12 @@ const AdminRoute = () => {
|
||||
},
|
||||
});
|
||||
if (data && user) {
|
||||
/*
|
||||
TODO: add permision check
|
||||
if (user.roles.org !== 'admin') {
|
||||
return <Redirect to="/" />;
|
||||
}
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
|
||||
@ -225,7 +228,8 @@ const AdminRoute = () => {
|
||||
initialTab={0}
|
||||
users={data.users}
|
||||
invitedUsers={data.invitedUsers}
|
||||
canInviteUser={user.roles.org === 'admin'}
|
||||
// canInviteUser={user.roles.org === 'admin'} TODO: add permision check
|
||||
canInviteUser={true}
|
||||
onInviteUser={NOOP}
|
||||
onUpdateUserPassword={() => {
|
||||
hidePopup();
|
||||
|
@ -13,8 +13,6 @@ import Login from 'Auth';
|
||||
import Register from 'Register';
|
||||
import Profile from 'Profile';
|
||||
import styled from 'styled-components';
|
||||
import JwtDecode from 'jwt-decode';
|
||||
import { setAccessToken } from 'shared/utils/accessToken';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
|
||||
const MainContent = styled.div`
|
||||
@ -26,9 +24,9 @@ const MainContent = styled.div`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
setup?: null | { confirmToken: string };
|
||||
type ValidateTokenResponse = {
|
||||
valid: boolean;
|
||||
userID: string;
|
||||
};
|
||||
|
||||
const AuthorizedRoutes = () => {
|
||||
@ -36,27 +34,17 @@ const AuthorizedRoutes = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { setUser } = useCurrentUser();
|
||||
useEffect(() => {
|
||||
fetch('/auth/refresh_token', {
|
||||
fetch('/auth/validate', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const { status } = x;
|
||||
if (status === 400) {
|
||||
history.replace('/login');
|
||||
const response: ValidateTokenResponse = await x.json();
|
||||
const { valid, userID } = response;
|
||||
if (!valid) {
|
||||
history.replace(`/login`);
|
||||
} else {
|
||||
const response: RefreshTokenResponse = await x.json();
|
||||
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);
|
||||
}
|
||||
setUser(userID);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
@ -3,13 +3,8 @@ import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
|
||||
import { ProfileMenu } from 'shared/components/DropdownMenu';
|
||||
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
||||
import { useHistory } from 'react-router';
|
||||
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
|
||||
import {
|
||||
RoleCode,
|
||||
useTopNavbarQuery,
|
||||
useDeleteProjectMutation,
|
||||
GetProjectsDocument,
|
||||
} from 'shared/generated/graphql';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import { RoleCode, useTopNavbarQuery, useDeleteProjectMutation, GetProjectsDocument } from 'shared/generated/graphql';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import produce from 'immer';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
@ -107,23 +102,10 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
onRemoveInvitedFromBoard,
|
||||
onRemoveFromBoard,
|
||||
}) => {
|
||||
const { user, setUserRoles, setUser } = useCurrentUser();
|
||||
const { user, setUser } = useCurrentUser();
|
||||
const { loading, data } = useTopNavbarQuery({
|
||||
onCompleted: response => {
|
||||
if (user && user.roles) {
|
||||
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>()),
|
||||
});
|
||||
}
|
||||
},
|
||||
// TODO: maybe remove?
|
||||
onCompleted: response => {},
|
||||
});
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const history = useHistory();
|
||||
@ -147,7 +129,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
<Popup title={null} tab={0}>
|
||||
<ProfileMenu
|
||||
onLogout={onLogout}
|
||||
showAdminConsole={user ? user.roles.org === 'admin' : false}
|
||||
showAdminConsole={true} // TODO: add permision check
|
||||
onAdminConsole={() => {
|
||||
history.push('/admin');
|
||||
hidePopup();
|
||||
@ -189,7 +171,9 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
if (!user) {
|
||||
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 member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
|
||||
if (member) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
import { InMemoryCache } from '@apollo/client';
|
||||
|
||||
const cache = new InMemoryCache();
|
||||
|
||||
|
@ -1,79 +1,20 @@
|
||||
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 = {
|
||||
user: CurrentUserRaw | null;
|
||||
setUser: (user: CurrentUserRaw | null) => void;
|
||||
setUserRoles: (roles: CurrentUserRoles) => void;
|
||||
user: string | null;
|
||||
setUser: (user: string | null) => void;
|
||||
};
|
||||
|
||||
export const UserContext = React.createContext<UserContextState>({
|
||||
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 = () => {
|
||||
const { user, setUser, setUserRoles } = 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
return {
|
||||
user: currentUser,
|
||||
user,
|
||||
setUser,
|
||||
setUserRoles,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,16 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { Router } from 'react-router';
|
||||
import { PopupProvider } from 'shared/components/PopupMenu';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { setAccessToken } from 'shared/utils/accessToken';
|
||||
import styled, { ThemeProvider } from 'styled-components';
|
||||
import NormalizeStyles from './NormalizeStyles';
|
||||
import BaseStyles from './BaseStyles';
|
||||
import theme from './ThemeStyles';
|
||||
import Routes from './Routes';
|
||||
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context';
|
||||
import { UserContext } from './context';
|
||||
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
@ -48,19 +46,11 @@ const StyledContainer = styled(ToastContainer).attrs({
|
||||
const history = createBrowserHistory();
|
||||
|
||||
const App = () => {
|
||||
const [user, setUser] = useState<CurrentUserRaw | null>(null);
|
||||
const setUserRoles = (roles: CurrentUserRoles) => {
|
||||
if (user) {
|
||||
setUser({
|
||||
...user,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
};
|
||||
const [user, setUser] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserContext.Provider value={{ user, setUser, setUserRoles }}>
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
|
@ -1,7 +1,5 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import JwtDecode from 'jwt-decode';
|
||||
import { setAccessToken } from 'shared/utils/accessToken';
|
||||
import Login from 'shared/components/Login';
|
||||
import UserContext from 'App/context';
|
||||
import { Container, LoginWrapper } from './Styles';
|
||||
@ -30,42 +28,23 @@ const Auth = () => {
|
||||
setComplete(true);
|
||||
} else {
|
||||
const response = await x.json();
|
||||
const { accessToken } = response;
|
||||
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);
|
||||
setComplete(true);
|
||||
setAccessToken(accessToken);
|
||||
|
||||
const { userID } = response;
|
||||
setUser(userID);
|
||||
history.push('/');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/auth/refresh_token', {
|
||||
fetch('/auth/validate', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
const response: RefreshTokenResponse = await x.json();
|
||||
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);
|
||||
history.replace('/projects');
|
||||
}
|
||||
const response = await x.json();
|
||||
const { valid, userID } = response;
|
||||
if (valid) {
|
||||
setUser(userID);
|
||||
history.replace('/projects');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
@ -5,8 +5,6 @@ import { useHistory, useLocation } from 'react-router';
|
||||
import * as QueryString from 'query-string';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Container, LoginWrapper } from './Styles';
|
||||
import JwtDecode from 'jwt-decode';
|
||||
import { setAccessToken } from 'shared/utils/accessToken';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
|
||||
const UsersConfirm = () => {
|
||||
@ -31,18 +29,8 @@ const UsersConfirm = () => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
const response = await x.json();
|
||||
const { accessToken } = response;
|
||||
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);
|
||||
const { userID } = response;
|
||||
setUser(userID);
|
||||
history.push('/');
|
||||
} else {
|
||||
setFailed();
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import { getAccessToken } from 'shared/utils/accessToken';
|
||||
import Settings from 'shared/components/Settings';
|
||||
import {
|
||||
useMeQuery,
|
||||
@ -49,12 +48,9 @@ const Projects = () => {
|
||||
if (e.target.files) {
|
||||
const fileData = new FormData();
|
||||
fileData.append('file', e.target.files[0]);
|
||||
const accessToken = getAccessToken();
|
||||
axios
|
||||
.post('/users/me/avatar', fileData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
withCredentials: true,
|
||||
})
|
||||
.then(res => {
|
||||
if ($fileUpload && $fileUpload.current) {
|
||||
@ -75,7 +71,7 @@ const Projects = () => {
|
||||
}
|
||||
}}
|
||||
onResetPassword={(password, done) => {
|
||||
updateUserPassword({ variables: { userID: user.id, password } });
|
||||
updateUserPassword({ variables: { userID: user, password } });
|
||||
toast('Password was changed!');
|
||||
done();
|
||||
}}
|
||||
|
@ -543,7 +543,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
||||
onChangeTaskMetaFilter={filter => {
|
||||
setTaskMetaFilters(filter);
|
||||
}}
|
||||
userID={user?.id}
|
||||
userID={user ?? ''}
|
||||
labels={labelsRef}
|
||||
members={membersRef}
|
||||
/>,
|
||||
|
@ -541,7 +541,7 @@ const Details: React.FC<DetailsProps> = ({
|
||||
bio="None"
|
||||
onRemoveFromTask={() => {
|
||||
if (user) {
|
||||
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
|
||||
unassignTask({ variables: { taskID: data.findTask.id, userID: user ?? '' } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
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 { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -268,7 +268,7 @@ const Projects = () => {
|
||||
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
|
||||
<Wrapper>
|
||||
<ProjectsContainer>
|
||||
{user.roles.org === 'admin' && (
|
||||
{true && ( // TODO: add permision check
|
||||
<AddTeamButton
|
||||
variant="outline"
|
||||
onClick={$target => {
|
||||
@ -330,7 +330,7 @@ const Projects = () => {
|
||||
<div key={team.id}>
|
||||
<ProjectSectionTitleWrapper>
|
||||
<ProjectSectionTitle>{team.name}</ProjectSectionTitle>
|
||||
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
|
||||
{true && ( // TODO: add permision check
|
||||
<SectionActions>
|
||||
<SectionActionLink to={`/teams/${team.id}`}>
|
||||
<SectionAction variant="outline">Projects</SectionAction>
|
||||
@ -355,7 +355,7 @@ const Projects = () => {
|
||||
</ProjectTile>
|
||||
</ProjectListItem>
|
||||
))}
|
||||
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
|
||||
{true && ( // TODO: add permision check
|
||||
<ProjectListItem>
|
||||
<ProjectAddTile
|
||||
onClick={() => {
|
||||
|
@ -3,7 +3,7 @@ import Input from 'shared/components/Input';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
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 {
|
||||
useGetTeamQuery,
|
||||
@ -424,7 +424,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: 3000,
|
||||
});
|
||||
const { user, setUserRoles } = useCurrentUser();
|
||||
const { user } = useCurrentUser();
|
||||
const warning =
|
||||
'You can’t 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({
|
||||
@ -446,17 +446,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation({
|
||||
onCompleted: r => {
|
||||
if (user) {
|
||||
setUserRoles(
|
||||
produce(user.roles, draftRoles => {
|
||||
draftRoles.teams.set(r.updateTeamMemberRole.teamID, r.updateTeamMemberRole.member.role.code);
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation();
|
||||
const [deleteTeamMember] = useDeleteTeamMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<GetTeamQuery>(
|
||||
@ -491,7 +481,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
|
||||
</ListDesc>
|
||||
<ListActions>
|
||||
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
|
||||
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, data.findTeam.id) && (
|
||||
{true && ( // TODO: add permission check
|
||||
<InviteMemberButton
|
||||
onClick={$target => {
|
||||
showPopup(
|
||||
@ -528,11 +518,12 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<TeamRoleManagerPopup
|
||||
currentUserID={user.id ?? ''}
|
||||
currentUserID={user ?? ''}
|
||||
subject={member}
|
||||
members={data.findTeam.members}
|
||||
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 => {
|
||||
updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } });
|
||||
}}
|
||||
|
@ -13,7 +13,7 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { History } from 'history';
|
||||
import produce from 'immer';
|
||||
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 Members from './Members';
|
||||
import Projects from './Projects';
|
||||
@ -95,9 +95,12 @@ const Teams = () => {
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const match = useRouteMatch();
|
||||
if (data && user) {
|
||||
/*
|
||||
TODO: re-add permission check
|
||||
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
|
||||
return <Redirect to="/" />;
|
||||
}
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar
|
||||
|
@ -1,20 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
||||
import { ApolloProvider } from '@apollo/react-hooks';
|
||||
import { ApolloClient } from 'apollo-client';
|
||||
import { HttpLink } from 'apollo-link-http';
|
||||
import { onError } from 'apollo-link-error';
|
||||
import { ApolloClient } from '@apollo/client';
|
||||
import { ApolloProvider } from '@apollo/client/react';
|
||||
|
||||
import { enableMapSet } from 'immer';
|
||||
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
|
||||
import dayjs from 'dayjs';
|
||||
import updateLocale from 'dayjs/plugin/updateLocale';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
|
||||
import cache from './App/cache';
|
||||
import App from './App';
|
||||
|
||||
@ -34,131 +29,8 @@ dayjs.updateLocale('en', {
|
||||
},
|
||||
});
|
||||
|
||||
let forward$;
|
||||
let isRefreshing = false;
|
||||
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,
|
||||
});
|
||||
const client = new ApolloClient({ uri: '/graphql', cache });
|
||||
console.log('cloient', client);
|
||||
|
||||
ReactDOM.render(
|
||||
<ApolloProvider client={client}>
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 Button from 'shared/components/Button';
|
||||
|
||||
|
@ -49,6 +49,7 @@ export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCa
|
||||
<ListNameEditorWrapper>
|
||||
<ListNameEditor
|
||||
ref={$editorRef}
|
||||
height={40}
|
||||
onKeyDown={onKeyDown}
|
||||
value={listName}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)}
|
||||
|
@ -10,6 +10,7 @@ export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
|
||||
z-index: ${props => props.zIndex};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>`
|
||||
${props =>
|
||||
props.color === 'success' &&
|
||||
@ -18,6 +19,7 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
|
||||
stroke: ${props.theme.colors.success};
|
||||
`}
|
||||
`;
|
||||
|
||||
export const ClockIcon = styled(Clock)<{ color: string }>`
|
||||
fill: ${props => props.color};
|
||||
`;
|
||||
@ -26,7 +28,7 @@ export const EditorTextarea = styled(TextareaAutosize)`
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
resize: none;
|
||||
height: 90px;
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
|
||||
background: none;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import visit from 'unist-util-visit';
|
||||
import emoji from 'node-emoji';
|
||||
import emoticon from 'emoticon';
|
||||
import { emoticon } from 'emoticon';
|
||||
import { Emoji } from 'emoji-mart';
|
||||
import React from 'react';
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
});
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { PermissionObjectType, PermissionLevel } from 'App/context';
|
||||
|
||||
export default function userCan(level: PermissionLevel, objectType: PermissionObjectType) {
|
||||
return false;
|
||||
}
|
5
frontend/src/taskcafe.d.ts
vendored
5
frontend/src/taskcafe.d.ts
vendored
@ -59,11 +59,6 @@ type User = TaskUser & {
|
||||
owned: RelatedList;
|
||||
};
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
setup?: null | { confirmToken: string };
|
||||
};
|
||||
|
||||
type LoginFormData = {
|
||||
username: string;
|
||||
password: string;
|
||||
|
@ -5397,10 +5397,10 @@ emojis-list@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
|
||||
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
|
||||
|
||||
emoticon@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-3.2.0.tgz#c008ca7d7620fac742fe1bf4af8ff8fed154ae7f"
|
||||
integrity sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg==
|
||||
emoticon@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-4.0.0.tgz#827be62e6fee2a47517187992358adc5297ca320"
|
||||
integrity sha512-Of6PNiAQGHhprBEootT77SdsynrVD97v1nzpiJ+FB2XCyvZmwdVI+hlpUduKWb2+7uVOcE1Myh3QAVB8vEBXvw==
|
||||
|
||||
encodeurl@~1.0.2:
|
||||
version "1.0.2"
|
||||
|
6
go.mod
6
go.mod
@ -3,11 +3,11 @@ module github.com/jordanknott/taskcafe
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.11.3
|
||||
github.com/99designs/gqlgen v0.13.0
|
||||
github.com/RichardKnop/machinery v1.9.1
|
||||
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/cors v1.2.0
|
||||
github.com/golang-migrate/migrate/v4 v4.11.0
|
||||
github.com/google/uuid 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/pflag v1.0.5 // indirect
|
||||
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
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
|
19
go.sum
19
go.sum
@ -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.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=
|
||||
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
|
||||
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
|
||||
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
|
||||
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/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
|
||||
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/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/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/go.mod h1:/ZENnKqX+XrN8SORLe/fu5lZDIo1tuPncWuRD+eyhSI=
|
||||
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/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 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
|
||||
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/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/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/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/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=
|
||||
@ -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.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
|
||||
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/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-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
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/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/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA=
|
||||
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/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/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/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
|
||||
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
||||
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
|
||||
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/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=
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
@ -86,6 +86,6 @@ func Execute() {
|
||||
viper.SetDefault("queue.store", "memcache://localhost:11211")
|
||||
|
||||
rootCmd.SetVersionTemplate(VersionTemplate())
|
||||
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newTokenCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
|
||||
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
|
||||
rootCmd.Execute()
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
@ -10,6 +10,13 @@ import (
|
||||
"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 {
|
||||
LabelColorID uuid.UUID `json:"label_color_id"`
|
||||
ColorHex string `json:"color_hex"`
|
||||
@ -74,13 +81,6 @@ type ProjectMemberInvited struct {
|
||||
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 {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
CreateAuthToken(ctx context.Context, arg CreateAuthTokenParams) (AuthToken, error)
|
||||
CreateConfirmToken(ctx context.Context, email string) (UserAccountConfirmToken, error)
|
||||
CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error)
|
||||
CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error)
|
||||
@ -20,7 +21,6 @@ type Querier interface {
|
||||
CreatePersonalProjectLink(ctx context.Context, arg CreatePersonalProjectLinkParams) (PersonalProject, error)
|
||||
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, 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)
|
||||
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
|
||||
CreateTaskActivity(ctx context.Context, arg CreateTaskActivityParams) (TaskActivity, error)
|
||||
@ -35,6 +35,8 @@ type Querier interface {
|
||||
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
|
||||
CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, 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
|
||||
DeleteExpiredTokens(ctx context.Context) error
|
||||
DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error
|
||||
@ -43,8 +45,6 @@ type Querier interface {
|
||||
DeleteProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) error
|
||||
DeleteProjectMember(ctx context.Context, arg DeleteProjectMemberParams) 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)
|
||||
DeleteTaskByID(ctx context.Context, taskID 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)
|
||||
GetAssignedTasksDueDateForUserID(ctx context.Context, arg GetAssignedTasksDueDateForUserIDParams) ([]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)
|
||||
GetConfirmTokenByEmail(ctx context.Context, email string) (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)
|
||||
GetProjectsForInvitedMember(ctx context.Context, email string) ([]uuid.UUID, 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)
|
||||
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
|
||||
GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error)
|
||||
|
@ -1,14 +1,14 @@
|
||||
-- name: GetRefreshTokenByID :one
|
||||
SELECT * FROM refresh_token WHERE token_id = $1;
|
||||
-- name: GetAuthTokenByID :one
|
||||
SELECT * FROM auth_token WHERE token_id = $1;
|
||||
|
||||
-- name: CreateRefreshToken :one
|
||||
INSERT INTO refresh_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING *;
|
||||
-- name: CreateAuthToken :one
|
||||
INSERT INTO auth_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING *;
|
||||
|
||||
-- name: DeleteRefreshTokenByID :exec
|
||||
DELETE FROM refresh_token WHERE token_id = $1;
|
||||
-- name: DeleteAuthTokenByID :exec
|
||||
DELETE FROM auth_token WHERE token_id = $1;
|
||||
|
||||
-- name: DeleteRefreshTokenByUserID :exec
|
||||
DELETE FROM refresh_token WHERE user_id = $1;
|
||||
-- name: DeleteAuthTokenByUserID :exec
|
||||
DELETE FROM auth_token WHERE user_id = $1;
|
||||
|
||||
-- name: DeleteExpiredTokens :exec
|
||||
DELETE FROM refresh_token WHERE expires_at <= NOW();
|
||||
DELETE FROM auth_token WHERE expires_at <= NOW();
|
||||
|
@ -10,19 +10,19 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createRefreshToken = `-- name: CreateRefreshToken :one
|
||||
INSERT INTO refresh_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING token_id, user_id, created_at, expires_at
|
||||
const createAuthToken = `-- name: CreateAuthToken :one
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, createRefreshToken, arg.UserID, arg.CreatedAt, arg.ExpiresAt)
|
||||
var i RefreshToken
|
||||
func (q *Queries) CreateAuthToken(ctx context.Context, arg CreateAuthTokenParams) (AuthToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, createAuthToken, arg.UserID, arg.CreatedAt, arg.ExpiresAt)
|
||||
var i AuthToken
|
||||
err := row.Scan(
|
||||
&i.TokenID,
|
||||
&i.UserID,
|
||||
@ -32,8 +32,26 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken
|
||||
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
|
||||
DELETE FROM refresh_token WHERE expires_at <= NOW()
|
||||
DELETE FROM auth_token WHERE expires_at <= NOW()
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredTokens(ctx context.Context) error {
|
||||
@ -41,31 +59,13 @@ func (q *Queries) DeleteExpiredTokens(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteRefreshTokenByID = `-- name: DeleteRefreshTokenByID :exec
|
||||
DELETE FROM refresh_token WHERE token_id = $1
|
||||
const getAuthTokenByID = `-- name: GetAuthTokenByID :one
|
||||
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 {
|
||||
_, err := q.db.ExecContext(ctx, deleteRefreshTokenByID, tokenID)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
func (q *Queries) GetAuthTokenByID(ctx context.Context, tokenID uuid.UUID) (AuthToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAuthTokenByID, tokenID)
|
||||
var i AuthToken
|
||||
err := row.Scan(
|
||||
&i.TokenID,
|
||||
&i.UserID,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,6 @@ import (
|
||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||
"github.com/99designs/gqlgen/graphql/playground"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/taskcafe/internal/auth"
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
"github.com/jordanknott/taskcafe/internal/logger"
|
||||
"github.com/jordanknott/taskcafe/internal/utils"
|
||||
@ -34,15 +33,18 @@ 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) {
|
||||
role, ok := GetUserRole(ctx)
|
||||
if !ok {
|
||||
return nil, errors.New("user ID is missing")
|
||||
}
|
||||
if role == "admin" {
|
||||
return next(ctx)
|
||||
} else if level == ActionLevelOrg {
|
||||
return nil, errors.New("must be an org admin")
|
||||
}
|
||||
/*
|
||||
TODO: add permission check
|
||||
role, ok := GetUserRole(ctx)
|
||||
if !ok {
|
||||
return nil, errors.New("user ID is missing")
|
||||
}
|
||||
if role == "admin" {
|
||||
return next(ctx)
|
||||
} else if level == ActionLevelOrg {
|
||||
return nil, errors.New("must be an org admin")
|
||||
}
|
||||
*/
|
||||
|
||||
var subjectID uuid.UUID
|
||||
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
|
||||
return next(ctx)
|
||||
}
|
||||
subjectID, ok = subjectField.Interface().(uuid.UUID)
|
||||
subjectID, ok := subjectField.Interface().(uuid.UUID)
|
||||
if !ok {
|
||||
logger.New(ctx).Error("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
|
||||
}
|
||||
|
||||
// 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
|
||||
func GetUser(ctx context.Context) (uuid.UUID, auth.Role, bool) {
|
||||
func GetUser(ctx context.Context) (uuid.UUID, bool) {
|
||||
userID, userOK := GetUserID(ctx)
|
||||
role, roleOK := GetUserRole(ctx)
|
||||
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
|
||||
return userID, userOK
|
||||
}
|
||||
|
||||
// GetProjectRoles retrieves the team & project role for the given project ID
|
||||
|
@ -255,6 +255,7 @@ type LogoutUser struct {
|
||||
|
||||
type MePayload struct {
|
||||
User *db.UserAccount `json:"user"`
|
||||
Organization *RoleCode `json:"organization"`
|
||||
TeamRoles []TeamRole `json:"teamRoles"`
|
||||
ProjectRoles []ProjectRole `json:"projectRoles"`
|
||||
}
|
||||
@ -312,10 +313,6 @@ type NewProjectLabel struct {
|
||||
Name *string `json:"name"`
|
||||
}
|
||||
|
||||
type NewRefreshToken struct {
|
||||
UserID uuid.UUID `json:"userID"`
|
||||
}
|
||||
|
||||
type NewTask struct {
|
||||
TaskGroupID uuid.UUID `json:"taskGroupID"`
|
||||
Name string `json:"name"`
|
||||
@ -382,6 +379,12 @@ type ProfileIcon struct {
|
||||
BgColor *string `json:"bgColor"`
|
||||
}
|
||||
|
||||
type ProjectPermission struct {
|
||||
Team RoleCode `json:"team"`
|
||||
Project RoleCode `json:"project"`
|
||||
Org RoleCode `json:"org"`
|
||||
}
|
||||
|
||||
type ProjectRole struct {
|
||||
ProjectID uuid.UUID `json:"projectID"`
|
||||
RoleCode RoleCode `json:"roleCode"`
|
||||
@ -435,6 +438,11 @@ type TaskPositionUpdate struct {
|
||||
Position float64 `json:"position"`
|
||||
}
|
||||
|
||||
type TeamPermission struct {
|
||||
Team RoleCode `json:"team"`
|
||||
Org RoleCode `json:"org"`
|
||||
}
|
||||
|
||||
type TeamRole struct {
|
||||
TeamID uuid.UUID `json:"teamID"`
|
||||
RoleCode RoleCode `json:"roleCode"`
|
||||
|
@ -50,13 +50,6 @@ type Member {
|
||||
member: MemberList!
|
||||
}
|
||||
|
||||
type RefreshToken {
|
||||
id: ID!
|
||||
userId: UUID!
|
||||
expiresAt: Time!
|
||||
createdAt: Time!
|
||||
}
|
||||
|
||||
type Role {
|
||||
code: String!
|
||||
name: String!
|
||||
@ -97,6 +90,7 @@ type Team {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
name: String!
|
||||
permission: TeamPermission!
|
||||
members: [Member!]!
|
||||
}
|
||||
|
||||
@ -106,6 +100,17 @@ type InvitedMember {
|
||||
invitedOn: Time!
|
||||
}
|
||||
|
||||
type TeamPermission {
|
||||
team: RoleCode!
|
||||
org: RoleCode!
|
||||
}
|
||||
|
||||
type ProjectPermission {
|
||||
team: RoleCode!
|
||||
project: RoleCode!
|
||||
org: RoleCode!
|
||||
}
|
||||
|
||||
type Project {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
@ -114,6 +119,7 @@ type Project {
|
||||
taskGroups: [TaskGroup!]!
|
||||
members: [Member!]!
|
||||
invitedMembers: [InvitedMember!]!
|
||||
permission: ProjectPermission!
|
||||
labels: [ProjectLabel!]!
|
||||
}
|
||||
|
||||
@ -314,6 +320,7 @@ type ProjectRole {
|
||||
|
||||
type MePayload {
|
||||
user: UserAccount!
|
||||
organization: RoleCode
|
||||
teamRoles: [TeamRole!]!
|
||||
projectRoles: [ProjectRole!]!
|
||||
}
|
||||
@ -881,7 +888,6 @@ type UpdateTeamMemberRolePayload {
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createRefreshToken(input: NewRefreshToken!): RefreshToken!
|
||||
createUserAccount(input: NewUserAccount!):
|
||||
UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
|
||||
deleteUserAccount(input: DeleteUserAccount!):
|
||||
@ -954,10 +960,6 @@ type UpdateUserRolePayload {
|
||||
user: UserAccount!
|
||||
}
|
||||
|
||||
input NewRefreshToken {
|
||||
userID: UUID!
|
||||
}
|
||||
|
||||
input NewUserAccount {
|
||||
username: String!
|
||||
email: String!
|
||||
|
@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jinzhu/now"
|
||||
"github.com/jordanknott/taskcafe/internal/auth"
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
"github.com/jordanknott/taskcafe/internal/logger"
|
||||
"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) {
|
||||
_, role, ok := GetUser(ctx)
|
||||
_, ok := GetUser(ctx)
|
||||
if !ok {
|
||||
return &db.Team{}, nil
|
||||
}
|
||||
if role == auth.RoleAdmin {
|
||||
// if role == auth.RoleAdmin { // TODO: add permision check
|
||||
if true {
|
||||
createdAt := time.Now().UTC()
|
||||
team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name})
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
_, role, ok := GetUser(ctx)
|
||||
_, ok := GetUser(ctx)
|
||||
if !ok {
|
||||
return &db.UserAccount{}, nil
|
||||
}
|
||||
if role != auth.RoleAdmin {
|
||||
// if role != auth.RoleAdmin { TODO: add permsion check
|
||||
if true {
|
||||
return &db.UserAccount{}, &gqlerror.Error{
|
||||
Message: "Must be an organization admin",
|
||||
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) {
|
||||
_, role, ok := GetUser(ctx)
|
||||
_, ok := GetUser(ctx)
|
||||
if !ok {
|
||||
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{
|
||||
Message: "User not found",
|
||||
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) {
|
||||
err := r.Repository.DeleteRefreshTokenByUserID(ctx, input.UserID)
|
||||
err := r.Repository.DeleteAuthTokenByUserID(ctx, input.UserID)
|
||||
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) {
|
||||
_, role, ok := GetUser(ctx)
|
||||
_, ok := GetUser(ctx)
|
||||
if !ok {
|
||||
return &UpdateUserRolePayload{}, nil
|
||||
}
|
||||
if role != auth.RoleAdmin {
|
||||
// if role != auth.RoleAdmin { TODO: add permision check
|
||||
if true {
|
||||
return &UpdateUserRolePayload{}, &gqlerror.Error{
|
||||
Message: "User not found",
|
||||
Extensions: map[string]interface{}{
|
||||
@ -1211,6 +1206,10 @@ func (r *projectResolver) InvitedMembers(ctx context.Context, obj *db.Project) (
|
||||
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) {
|
||||
labels, err := r.Repository.GetProjectLabelsForProject(ctx, obj.ProjectID)
|
||||
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) {
|
||||
userID, orgRole, ok := GetUser(ctx)
|
||||
userID, ok := GetUser(ctx)
|
||||
if !ok {
|
||||
logger.New(ctx).Info("user id was not found from middleware")
|
||||
return []db.Project{}, nil
|
||||
@ -1309,11 +1308,14 @@ func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]
|
||||
|
||||
var teams []db.Team
|
||||
var err error
|
||||
/* TODO: add permsion check
|
||||
if orgRole == "admin" {
|
||||
teams, err = r.Repository.GetAllTeams(ctx)
|
||||
} else {
|
||||
teams, err = r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
|
||||
}
|
||||
*/
|
||||
teams, err = r.Repository.GetAllTeams(ctx)
|
||||
|
||||
projects := make(map[string]db.Project)
|
||||
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) {
|
||||
userID, orgRole, ok := GetUser(ctx)
|
||||
userID, ok := GetUser(ctx)
|
||||
if !ok {
|
||||
logger.New(ctx).Error("userID or org role does not exist")
|
||||
return []db.Team{}, errors.New("internal error")
|
||||
}
|
||||
if orgRole == "admin" {
|
||||
|
||||
return r.Repository.GetAllTeams(ctx)
|
||||
}
|
||||
/*
|
||||
TODO: add permision check
|
||||
if orgRole == "admin" {
|
||||
return r.Repository.GetAllTeams(ctx)
|
||||
}
|
||||
*/
|
||||
|
||||
teams := make(map[string]db.Team)
|
||||
adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
|
||||
@ -1596,10 +1601,6 @@ func (r *queryResolver) SearchMembers(ctx context.Context, input MemberSearchFil
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
members := []Member{}
|
||||
|
||||
@ -1966,9 +1971,6 @@ func (r *Resolver) ProjectLabel() ProjectLabelResolver { return &projectLabelRes
|
||||
// Query returns QueryResolver implementation.
|
||||
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
||||
|
||||
// RefreshToken returns RefreshTokenResolver implementation.
|
||||
func (r *Resolver) RefreshToken() RefreshTokenResolver { return &refreshTokenResolver{r} }
|
||||
|
||||
// Task returns TaskResolver implementation.
|
||||
func (r *Resolver) Task() TaskResolver { return &taskResolver{r} }
|
||||
|
||||
@ -2005,7 +2007,6 @@ type organizationResolver struct{ *Resolver }
|
||||
type projectResolver struct{ *Resolver }
|
||||
type projectLabelResolver struct{ *Resolver }
|
||||
type queryResolver struct{ *Resolver }
|
||||
type refreshTokenResolver struct{ *Resolver }
|
||||
type taskResolver struct{ *Resolver }
|
||||
type taskActivityResolver struct{ *Resolver }
|
||||
type taskChecklistResolver struct{ *Resolver }
|
||||
|
@ -50,13 +50,6 @@ type Member {
|
||||
member: MemberList!
|
||||
}
|
||||
|
||||
type RefreshToken {
|
||||
id: ID!
|
||||
userId: UUID!
|
||||
expiresAt: Time!
|
||||
createdAt: Time!
|
||||
}
|
||||
|
||||
type Role {
|
||||
code: String!
|
||||
name: String!
|
||||
@ -97,6 +90,7 @@ type Team {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
name: String!
|
||||
permission: TeamPermission!
|
||||
members: [Member!]!
|
||||
}
|
||||
|
||||
@ -106,6 +100,17 @@ type InvitedMember {
|
||||
invitedOn: Time!
|
||||
}
|
||||
|
||||
type TeamPermission {
|
||||
team: RoleCode!
|
||||
org: RoleCode!
|
||||
}
|
||||
|
||||
type ProjectPermission {
|
||||
team: RoleCode!
|
||||
project: RoleCode!
|
||||
org: RoleCode!
|
||||
}
|
||||
|
||||
type Project {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
@ -114,6 +119,7 @@ type Project {
|
||||
taskGroups: [TaskGroup!]!
|
||||
members: [Member!]!
|
||||
invitedMembers: [InvitedMember!]!
|
||||
permission: ProjectPermission!
|
||||
labels: [ProjectLabel!]!
|
||||
}
|
||||
|
||||
|
@ -90,6 +90,7 @@ type ProjectRole {
|
||||
|
||||
type MePayload {
|
||||
user: UserAccount!
|
||||
organization: RoleCode
|
||||
teamRoles: [TeamRole!]!
|
||||
projectRoles: [ProjectRole!]!
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
extend type Mutation {
|
||||
createRefreshToken(input: NewRefreshToken!): RefreshToken!
|
||||
createUserAccount(input: NewUserAccount!):
|
||||
UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
|
||||
deleteUserAccount(input: DeleteUserAccount!):
|
||||
@ -72,10 +71,6 @@ type UpdateUserRolePayload {
|
||||
user: UserAccount!
|
||||
}
|
||||
|
||||
input NewRefreshToken {
|
||||
userID: UUID!
|
||||
}
|
||||
|
||||
input NewUserAccount {
|
||||
username: String!
|
||||
email: String!
|
||||
|
@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/taskcafe/internal/auth"
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@ -54,10 +53,15 @@ type Setup struct {
|
||||
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
|
||||
type LoginResponseData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
Setup bool `json:"setup"`
|
||||
UserID string `json:"userID"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
// LogoutResponseData is the response data for when a user logs out
|
||||
@ -65,8 +69,8 @@ type LogoutResponseData struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// RefreshTokenResponseData is the response data for when an access token is refreshed
|
||||
type RefreshTokenResponseData struct {
|
||||
// AuthTokenResponseData is the response data for when an access token is refreshed
|
||||
type AuthTokenResponseData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
|
||||
@ -76,93 +80,9 @@ type AvatarUploadResponseData struct {
|
||||
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
|
||||
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 == http.ErrNoCookie {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
@ -172,7 +92,7 @@ func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
refreshTokenID := uuid.MustParse(c.Value)
|
||||
err = h.repo.DeleteRefreshTokenByID(r.Context(), refreshTokenID)
|
||||
err = h.repo.DeleteAuthTokenByID(r.Context(), refreshTokenID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
@ -216,87 +136,23 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
refreshCreatedAt := time.Now().UTC()
|
||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||
authCreatedAt := time.Now().UTC()
|
||||
authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
|
||||
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 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refreshToken",
|
||||
Value: refreshTokenString.TokenID.String(),
|
||||
Expires: refreshExpiresAt,
|
||||
Name: "authToken",
|
||||
Value: authToken.TokenID.String(),
|
||||
Expires: authExpiresAt,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
||||
}
|
||||
|
||||
// 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})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{Complete: true, UserID: authToken.UserID.String()})
|
||||
}
|
||||
|
||||
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()
|
||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||
authCreatedAt := time.Now().UTC()
|
||||
authExpiresAt := authCreatedAt.AddDate(0, 0, 1)
|
||||
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 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refreshToken",
|
||||
Value: refreshTokenString.TokenID.String(),
|
||||
Expires: refreshExpiresAt,
|
||||
Name: "authToken",
|
||||
Value: authToken.TokenID.String(),
|
||||
Path: "/",
|
||||
Expires: authExpiresAt,
|
||||
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) {
|
||||
@ -425,6 +301,7 @@ func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error checking for active user")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasActiveUser {
|
||||
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 {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/login", taskcafeHandler.LoginHandler)
|
||||
r.Post("/refresh_token", taskcafeHandler.RefreshTokenHandler)
|
||||
r.Post("/logout", taskcafeHandler.LogoutHandler)
|
||||
r.Post("/validate", taskcafeHandler.ValidateAuthTokenHandler)
|
||||
return r
|
||||
}
|
||||
|
@ -3,35 +3,51 @@ package route
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/taskcafe/internal/auth"
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
"github.com/jordanknott/taskcafe/internal/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header
|
||||
type AuthenticationMiddleware struct {
|
||||
jwtKey []byte
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
// Middleware returns the middleware handler
|
||||
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := uuid.New()
|
||||
bearerTokenRaw := r.Header.Get("Authorization")
|
||||
splitToken := strings.Split(bearerTokenRaw, "Bearer")
|
||||
if len(splitToken) != 2 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accessTokenString := strings.TrimSpace(splitToken[1])
|
||||
accessClaims, err := auth.ValidateAccessToken(accessTokenString, m.jwtKey)
|
||||
foundToken := true
|
||||
tokenRaw := ""
|
||||
c, err := r.Cookie("authToken")
|
||||
if err != nil {
|
||||
if _, ok := err.(*auth.ErrExpiredToken); ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{
|
||||
if err == http.ErrNoCookie {
|
||||
foundToken = false
|
||||
} else {
|
||||
log.WithError(err).Error("unknown error")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
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 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{
|
||||
"data": {},
|
||||
"errors": [
|
||||
{
|
||||
@ -41,27 +57,12 @@ func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
}
|
||||
]
|
||||
}`))
|
||||
return
|
||||
}
|
||||
log.Error(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID uuid.UUID
|
||||
if accessClaims.Restricted == auth.InstallOnly {
|
||||
userID = uuid.New()
|
||||
} 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(r.Context(), utils.UserIDKey, token.UserID)
|
||||
// ctx = context.WithValue(ctx, utils.RestrictedModeKey, accessClaims.Restricted)
|
||||
// ctx = context.WithValue(ctx, utils.OrgRoleKey, accessClaims.OrgRole)
|
||||
ctx = context.WithValue(ctx, utils.ReqIDKey, requestID)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/jmoiron/sqlx"
|
||||
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.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)
|
||||
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/register", taskcafeHandler.RegisterUser)
|
||||
})
|
||||
auth := AuthenticationMiddleware{securityConfig.Secret}
|
||||
auth := AuthenticationMiddleware{*repository}
|
||||
r.Group(func(mux chi.Router) {
|
||||
mux.Use(auth.Middleware)
|
||||
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
|
||||
|
@ -0,0 +1 @@
|
||||
ALTER TABLE refresh_token RENAME TO auth_token;
|
Loading…
Reference in New Issue
Block a user