Skip to content

Commit

Permalink
Add user authentication and session management
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed May 15, 2024
1 parent 148dd38 commit f3dfecd
Show file tree
Hide file tree
Showing 12 changed files with 202 additions and 257 deletions.
5 changes: 5 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"jsx-a11y/label-has-associated-control": ["error", {
"controlComponents": ["Input", "DateInput"]
}]
},
"overrides": [
{
"files": ["**/app/routes/**", "vite*.config.ts"],
Expand Down
8 changes: 4 additions & 4 deletions app/auth/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@ import type { Strategy } from 'remix-auth';
import { Authenticator } from 'remix-auth';
import { FormStrategy } from 'remix-auth-form';
import type { UsersService } from '../users/UsersService.server';
import type { SessionStorage } from './session.server';
import type { SessionData, SessionStorage } from './session.server';

export const CREDENTIALS_STRATEGY = 'credentials';

function getAuthStrategies(usersService: UsersService): Map<string, Strategy<any, any>> {
const strategies = new Map<string, Strategy<any, any>>();

// Add strategy to login via credentials form
strategies.set(CREDENTIALS_STRATEGY, new FormStrategy(async ({ form }) => {
strategies.set(CREDENTIALS_STRATEGY, new FormStrategy(async ({ form }): Promise<SessionData> => {
const username = form.get('username');
const password = form.get('password');
if (typeof username !== 'string' || typeof password !== 'string') {
// TODO Check if this is the right way to handle this error
throw new Error('Username or password missing');
}

return usersService.getUserByCredentials(username, password);
const user = await usersService.getUserByCredentials(username, password);
return { userId: user.id };
}));

// TODO Add other strategies, like oAuth for SSO
Expand Down
6 changes: 3 additions & 3 deletions app/auth/passwords.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import bcrypt from 'bcrypt';
import * as argon2 from 'argon2';

export const hashPassword = async (plainTextPassword: string) => bcrypt.hash(plainTextPassword, 10);
export const hashPassword = async (plainTextPassword: string) => argon2.hash(plainTextPassword);

export const verifyPassword = async (plainTextPassword: string, hashedPassword: string) =>
bcrypt.compare(plainTextPassword, hashedPassword);
argon2.verify(hashedPassword, plainTextPassword);
5 changes: 3 additions & 2 deletions app/auth/session.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { createCookieSessionStorage } from '@remix-run/node';
import { env, isProd } from '../utils/env.server';

export type SessionData = {
userId: string;
userId: number;
[key: string]: unknown;
};

export const createSessionStorage = () => createCookieSessionStorage<SessionData>({
cookie: {
name: '__shlink_dashboard_session',
name: 'shlink_dashboard_session',
httpOnly: true,
maxAge: 30 * 60, // 30 minutes
path: '/',
Expand Down
2 changes: 1 addition & 1 deletion app/container/container.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ bottle.service(UsersService.name, UsersService, 'em');

bottle.constant('apiClientBuilder', apiClientBuilder);
bottle.serviceFactory('sessionStorage', createSessionStorage);
bottle.serviceFactory(Authenticator.name, createAuthenticator, 'em', 'sessionStorage');
bottle.serviceFactory(Authenticator.name, createAuthenticator, UsersService.name, 'sessionStorage');

export const { container: serverContainer } = bottle;
2 changes: 1 addition & 1 deletion app/db/data-source.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function resolveOptions(): DataSourceOptions {
password: env.SHLINK_DASHBOARD_DB_PASSWORD,
database: env.SHLINK_DASHBOARD_DB_NAME ?? 'shlink_dashboard',
synchronize: false,
logging: !isProd(),
logging: false,
entities: [UserEntity, SettingsEntity, TagEntity, ServerEntity],
migrations: ['app/db/migrations/*.ts'], // FIXME These won't work when bundling for prod. Revisit
};
Expand Down
16 changes: 15 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
import type { LoaderFunctionArgs } from '@remix-run/node';
import { Links, Meta, Outlet, Scripts } from '@remix-run/react';
import { Authenticator } from 'remix-auth';
import { MainHeader } from './common/MainHeader';
import { serverContainer } from './container/container.server';
import { appDataSource } from './db/data-source.server';
import './index.scss';

export async function loader() {
export async function loader(
{ request }: LoaderFunctionArgs,
authenticator: Authenticator = serverContainer[Authenticator.name],
) {
if (!appDataSource.isInitialized) {
console.log('Initializing database connection...');

Check warning on line 14 in app/root.tsx

View workflow job for this annotation

GitHub Actions / ci / lint (npm run lint)

Unexpected console statement
await appDataSource.initialize();
console.log('Database connection initialized');

Check warning on line 16 in app/root.tsx

View workflow job for this annotation

GitHub Actions / ci / lint (npm run lint)

Unexpected console statement
}

const { pathname } = new URL(request.url);
const isPublicRoute = ['/login'].includes(pathname);
if (!isPublicRoute) {
await authenticator.isAuthenticated(request, {
failureRedirect: `/login?redirect-to=${encodeURIComponent(pathname)}`,
});
}

return {};
}

Expand Down
62 changes: 62 additions & 0 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { useId } from 'react';
import { Button, Input } from 'reactstrap';
import { Authenticator } from 'remix-auth';
import { CREDENTIALS_STRATEGY } from '../auth/auth.server';
import type { SessionStorage } from '../auth/session.server';
import { serverContainer } from '../container/container.server';

export async function action(
{ request }: ActionFunctionArgs,
authenticator: Authenticator = serverContainer[Authenticator.name],
) {
const { searchParams } = new URL(request.url);
return authenticator.authenticate(CREDENTIALS_STRATEGY, request, {
successRedirect: searchParams.get('redirect-to') ?? '/', // TODO Make sure "redirect-to" is a relative URL
failureRedirect: request.url,
});
}

export async function loader(
{ request }: LoaderFunctionArgs,
authenticator: Authenticator = serverContainer[Authenticator.name],
{ getSession, commitSession }: SessionStorage = serverContainer.sessionStorage,
) {
// If the user is already authenticated redirect to home
await authenticator.isAuthenticated(request, { successRedirect: '/' });

const session = await getSession(request.headers.get('cookie'));
const error = session.get(authenticator.sessionErrorKey);
return json({ hasError: !!error }, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}

export default function Login() {
const usernameId = useId();
const passwordId = useId();
const { hasError } = useLoaderData<typeof loader>();

return (
<form
method="post"
className="d-flex flex-column gap-3 p-3 mt-5 mx-auto w-50 rounded-2 border-opacity-25 border-secondary"
style={{ borderWidth: '1px', borderStyle: 'solid' }}
>
<div>
<label htmlFor={usernameId}>Username:</label>
<Input id={usernameId} name="username" />
</div>
<div>
<label htmlFor={passwordId}>Password:</label>
<Input id={passwordId} type="password" name="password" />
</div>
<Button color="primary" type="submit">Login</Button>
{hasError && <div className="text-danger">Username or password are incorrect</div>}
</form>
);
}
2 changes: 1 addition & 1 deletion app/routes/server.$serverId.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function loader(

export default function ShlinkWebComponentContainer() {
const [component, setComponent] = useState<ReactNode>(null);
const { settings, tagColors } = useLoaderData<ReturnType<typeof loader>>();
const { settings, tagColors } = useLoaderData<typeof loader>();
const params = useParams();
const { serverId } = params;
const { pathname } = useLocation();
Expand Down
Loading

0 comments on commit f3dfecd

Please sign in to comment.