Skip to content

Commit

Permalink
Merge pull request #40 from acelaya-forks/feature/user-session
Browse files Browse the repository at this point in the history
Add services needed for sessions and end-user authentication
  • Loading branch information
acelaya authored May 16, 2024
2 parents ebe1b01 + 05dc13e commit 429f7d9
Show file tree
Hide file tree
Showing 24 changed files with 497 additions and 74 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
35 changes: 35 additions & 0 deletions app/auth/auth.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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 { credentialsSchema } from './credentials-schema.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 }): Promise<SessionData> => {
const { username, password } = credentialsSchema.parse({
username: form.get('username'),
password: form.get('password'),
});

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

// TODO Add other strategies, like oAuth for SSO

return strategies;
}

export function createAuthenticator(usersService: UsersService, sessionStorage: SessionStorage): Authenticator {
const authenticator = new Authenticator(sessionStorage);
const strategies = getAuthStrategies(usersService);
strategies.forEach((strategy, name) => authenticator.use(strategy, name));

return authenticator;
}
6 changes: 6 additions & 0 deletions app/auth/credentials-schema.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { z } from 'zod';

export const credentialsSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
6 changes: 6 additions & 0 deletions app/auth/passwords.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as argon2 from 'argon2';

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

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

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

export const createSessionStorage = () => createCookieSessionStorage<SessionData>({
cookie: {
name: 'shlink_dashboard_session',
httpOnly: true,
maxAge: 30 * 60, // 30 minutes
path: '/',
sameSite: 'lax',
secrets: env.SHLINK_DASHBOARD_SESSION_SECRETS ?? ['s3cr3t1'],
secure: isProd(),
},
});

export type SessionStorage = ReturnType<typeof createSessionStorage>;
7 changes: 7 additions & 0 deletions app/container/container.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import Bottle from 'bottlejs';
import { Authenticator } from 'remix-auth';
import { apiClientBuilder } from '../api/apiClientBuilder.server';
import { createAuthenticator } from '../auth/auth.server';
import { createSessionStorage } from '../auth/session.server';
import { appDataSource } from '../db/data-source.server';
import { ServersService } from '../servers/ServersService.server';
import { SettingsService } from '../settings/SettingsService.server';
import { TagsService } from '../tags/TagsService.server';
import { UsersService } from '../users/UsersService.server';

const bottle = new Bottle();

Expand All @@ -12,7 +16,10 @@ bottle.serviceFactory('em', () => appDataSource.manager);
bottle.service(TagsService.name, TagsService, 'em');
bottle.service(ServersService.name, ServersService, 'em');
bottle.service(SettingsService.name, SettingsService, 'em');
bottle.service(UsersService.name, UsersService, 'em');

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

export const { container: serverContainer } = bottle;
4 changes: 2 additions & 2 deletions app/db/data-source.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SettingsEntity } from '../entities/Settings';
import { TagEntity } from '../entities/Tag';
import { UserEntity } from '../entities/User';
import type { DbEngine } from '../utils/env.server';
import { env, isProd } from '../utils/env.server';
import { env } from '../utils/env.server';

const DEFAULT_PORTS: Record<Exclude<DbEngine, 'sqlite'>, number | undefined> = {
mysql: 3306,
Expand All @@ -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({ error }, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}

export default function Login() {
const usernameId = useId();
const passwordId = useId();
const { error } = 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" required />
</div>
<div>
<label htmlFor={passwordId}>Password:</label>
<Input id={passwordId} type="password" name="password" required />
</div>
<Button color="primary" type="submit">Login</Button>
{!!error && <div className="text-danger" data-testid="error-message">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
22 changes: 22 additions & 0 deletions app/users/UsersService.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { EntityManager } from 'typeorm';
import { verifyPassword } from '../auth/passwords.server';
import type { User } from '../entities/User';
import { UserEntity } from '../entities/User';

export class UsersService {
constructor(private readonly em: EntityManager) {}

async getUserByCredentials(username: string, password: string): Promise<User> {
const user = await this.em.findOneBy(UserEntity, { username });
if (!user) {
throw new Error(`User not found with username ${username}`);
}

const isPasswordCorrect = await verifyPassword(password, user.password);
if (!isPasswordCorrect) {
throw new Error(`Incorrect password for user ${username}`);
}

return user;
}
}
6 changes: 6 additions & 0 deletions app/utils/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ const envVariables = z.object({
SHLINK_DASHBOARD_DB_USER: z.string().optional(),
SHLINK_DASHBOARD_DB_PASSWORD: z.string().optional(),
SHLINK_DASHBOARD_DB_NAME: z.string().optional(),

// Sessions
SHLINK_DASHBOARD_SESSION_SECRETS: z.string().transform(
// Split the comma-separated list of secrets
(secrets) => secrets.split(',').map((v) => v.trim()),
).optional(),
});

export const env = envVariables.parse(process.env);
Expand Down
Loading

0 comments on commit 429f7d9

Please sign in to comment.