Skip to content
This repository has been archived by the owner on Aug 6, 2024. It is now read-only.

Commit

Permalink
Merge tag 'v1.10.0' into 79-update_1.10.0
Browse files Browse the repository at this point in the history
  • Loading branch information
cmeessen committed Nov 7, 2022
2 parents 954e348 + dbfbe7e commit 34b9954
Show file tree
Hide file tree
Showing 45 changed files with 577 additions and 391 deletions.
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,5 @@ keywords:
- Software Impact
- Software Reuse
license: Apache-2.0
version: v1.9.0
date-released: '2022-10-28'
version: v1.10.0
date-released: '2022-11-04'
30 changes: 16 additions & 14 deletions authentication/README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
<!--
SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center) <e.cahen@esciencecenter.nl>
SPDX-FileCopyrightText: 2022 Netherlands eScience Center
SPDX-FileCopyrightText: 2022 dv4all
SPDX-License-Identifier: CC-BY-4.0
-->

# Authentication module

This modules handles authentication from third parties using oAuth2 and OpenID.
This module handles authentication from third parties using oAuth2 and OpenID.

## Environment variables
Check `.env.example` to see which environment variables are needed.

## Developing locally
If you want to develop and run the auth module locally, i.e. outside of Docker, you have to make two changes to files tracked by Git.
1. In `docker-compose.yml`, add the following lines to the `nginx` service:
```yml
extra_hosts:
- "host.docker.internal:host-gateway"
```
2. In `nginx.conf`, replace `server auth:7000;` with `server host.docker.internal:7000;`

It requires the following variables at run time.

```env
# connection to backend
POSTGREST_URL=
# SURFconext
NEXT_PUBLIC_SURFCONEXT_CLIENT_ID=
NEXT_PUBLIC_SURFCONEXT_REDIRECT=
AUTH_SURFCONEXT_CLIENT_SECRET=
Remember to undo these changes before committing!

# JWT secret for postgREST
PGRST_JWT_SECRET=
```
It is recommended to use the [envFile plugin](https://plugins.jetbrains.com/plugin/7861-envfile) of IntelliJ IDEA to load your `.env` file.
Furthermore, set the value of `POSTGREST_URL` to `http://localhost/api/v1`.

## Running the tests

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,28 @@
import com.auth0.jwt.exceptions.JWTVerificationException;
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.ForbiddenResponse;
import io.javalin.http.RedirectResponse;

import java.util.Base64;

public class Main {
static final long ONE_HOUR_IN_SECONDS = 3600; // 60 * 60
static final long ONE_MINUTE_IN_SECONDS = 60;

public static boolean userIsAllowed (OpenIdInfo info) {
public static boolean userIsAllowed(OpenIdInfo info) {
String whitelist = Config.userMailWhitelist();

if (whitelist == null || whitelist.length() == 0) {
// allow any user
return true;
}

if (
info == null || info.email() == null || info.email().length() == 0
) {
if (info == null || info.email() == null || info.email().length() == 0) {
throw new Error("Unexpected parameters for 'userIsAllowed'");
}

String[] allowed = whitelist.split(";");

for (String s: allowed) {
for (String s : allowed) {
if (s.equalsIgnoreCase(info.email())) {
return true;
}
Expand All @@ -54,83 +51,63 @@ public static void main(String[] args) {
System.out.println("Warning: local accounts are enabled, this is not safe for production!");
System.out.println("********************");
app.post("/login/local", ctx -> {
try {
String sub = ctx.formParam("sub");
if (sub == null || sub.isBlank()) throw new RuntimeException("Please provide a username");
String name = sub;
String email = sub + "@example.com";
String organisation = "Example organisation";
OpenIdInfo localInfo = new OpenIdInfo(sub, name, email, organisation);

AccountInfo accountInfo = new PostgrestAccount().account(localInfo, OpenidProvider.local);
boolean isAdmin = isAdmin(email);
createAndSetToken(ctx, accountInfo, isAdmin);
} catch (RuntimeException ex) {
ex.printStackTrace();
ctx.redirect("/login/failed");
}
String sub = ctx.formParam("sub");
if (sub == null || sub.isBlank()) throw new RuntimeException("Please provide a username");
String name = sub;
String email = sub + "@example.com";
String organisation = "Example organisation";
OpenIdInfo localInfo = new OpenIdInfo(sub, name, email, organisation);

AccountInfo accountInfo = new PostgrestAccount().account(localInfo, OpenidProvider.local);
boolean isAdmin = isAdmin(email);
createAndSetToken(ctx, accountInfo, isAdmin);
});
}

if (Config.isSurfConextEnabled()) {
app.post("/login/surfconext", ctx -> {
try {
String code = ctx.formParam("code");
String redirectUrl = Config.surfconextRedirect();
OpenIdInfo surfconextInfo = new SurfconextLogin(code, redirectUrl).openidInfo();

if (!userIsAllowed(surfconextInfo)) {
throw new RuntimeException("User is not whitelisted");
}

AccountInfo accountInfo = new PostgrestAccount().account(surfconextInfo, OpenidProvider.surfconext);
String email = surfconextInfo.email();
boolean isAdmin = isAdmin(email);
createAndSetToken(ctx, accountInfo, isAdmin);
} catch (RuntimeException ex) {
ex.printStackTrace();
ctx.redirect("/login/failed");
String code = ctx.formParam("code");
String redirectUrl = Config.surfconextRedirect();
OpenIdInfo surfconextInfo = new SurfconextLogin(code, redirectUrl).openidInfo();

if (!userIsAllowed(surfconextInfo)) {
throw new RsdAuthenticationException("Your email address (" + surfconextInfo.email() + ") is not whitelisted.");
}

AccountInfo accountInfo = new PostgrestAccount().account(surfconextInfo, OpenidProvider.surfconext);
String email = surfconextInfo.email();
boolean isAdmin = isAdmin(email);
createAndSetToken(ctx, accountInfo, isAdmin);
});
}

if (Config.isHelmholtzEnabled()) {
app.get("/login/helmholtzaai", ctx -> {
try {
String code = ctx.queryParam("code");
String redirectUrl = Config.helmholtzAaiRedirect();
OpenIdInfo helmholtzInfo = new HelmholtzAaiLogin(code, redirectUrl).openidInfo();

if (!userIsAllowed(helmholtzInfo)) {
throw new RuntimeException("User is not whitelisted");
}

AccountInfo accountInfo = new PostgrestAccount().account(helmholtzInfo, OpenidProvider.helmholtz);
String email = helmholtzInfo.email();
boolean isAdmin = isAdmin(email);
createAndSetToken(ctx, accountInfo, isAdmin);
} catch (RuntimeException ex) {
ex.printStackTrace();
ctx.redirect("/login/failed");
String code = ctx.queryParam("code");
String redirectUrl = Config.helmholtzAaiRedirect();
OpenIdInfo helmholtzInfo = new HelmholtzAaiLogin(code, redirectUrl).openidInfo();

if (!userIsAllowed(helmholtzInfo)) {
throw new RsdAuthenticationException("Your email address (" + helmholtzInfo.email() + ") is not whitelisted.");
}

AccountInfo accountInfo = new PostgrestAccount().account(helmholtzInfo, OpenidProvider.helmholtz);
String email = helmholtzInfo.email();
boolean isAdmin = isAdmin(email);
createAndSetToken(ctx, accountInfo, isAdmin);
});
}

if (Config.isOrcidEnabled()) {
app.get("/login/orcid", ctx -> {
try {
String code = ctx.queryParam("code");
String redirectUrl = Config.orcidRedirect();
OpenIdInfo orcidInfo = new OrcidLogin(code, redirectUrl).openidInfo();

AccountInfo accountInfo = new PostgrestCheckOrcidWhitelistedAccount(new PostgrestAccount()).account(orcidInfo, OpenidProvider.orcid);
String email = orcidInfo.email();
boolean isAdmin = isAdmin(email);
createAndSetToken(ctx, accountInfo, isAdmin);
} catch (RuntimeException ex) {
ex.printStackTrace();
ctx.redirect("/login/failed");
}
String code = ctx.queryParam("code");
String redirectUrl = Config.orcidRedirect();
OpenIdInfo orcidInfo = new OrcidLogin(code, redirectUrl).openidInfo();

AccountInfo accountInfo = new PostgrestCheckOrcidWhitelistedAccount(new PostgrestAccount()).account(orcidInfo, OpenidProvider.orcid);
String email = orcidInfo.email();
boolean isAdmin = isAdmin(email);
createAndSetToken(ctx, accountInfo, isAdmin);
});
}

Expand All @@ -156,6 +133,17 @@ public static void main(String[] args) {
ctx.status(400);
ctx.json("{\"message\": \"invalid JWT\"}");
});

app.exception(RsdAuthenticationException.class, (ex, ctx) -> {
setLoginFailureCookie(ctx, ex.getMessage());
ctx.redirect("/login/failed");
});

app.exception(RuntimeException.class, (ex, ctx) -> {
ex.printStackTrace();
setLoginFailureCookie(ctx, "Something unexpected went wrong, please try again or contact us.");
ctx.redirect("/login/failed");
});
}

static boolean isAdmin(String email) {
Expand All @@ -173,6 +161,10 @@ static void setJwtCookie(Context ctx, String token) {
ctx.header("Set-Cookie", "rsd_token=" + token + "; Secure; HttpOnly; Path=/; SameSite=Lax; Max-Age=" + ONE_HOUR_IN_SECONDS);
}

static void setLoginFailureCookie(Context ctx, String message) {
ctx.header("Set-Cookie", "rsd_login_failure_message=" + message + "; Secure; Path=/login/failed; SameSite=Lax; Max-Age=" + ONE_MINUTE_IN_SECONDS);
}

static void setRedirectFromCookie(Context ctx) {
String returnPath = ctx.cookie("rsd_pathname");
if (returnPath != null && !returnPath.isBlank()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public AccountInfo account(OpenIdInfo openIdInfo, OpenidProvider provider) {
JwtCreator jwtCreator = new JwtCreator(Config.jwtSigningSecret());
String token = jwtCreator.createAdminJwt();
String response = PostgrestAccount.getAsAdmin(queryUri, token);
if (!orcidInResponse(orcid, response)) throw new AuthenticationException("Your ORCID (" + orcid + ") is not whitelisted.");
if (!orcidInResponse(orcid, response)) throw new RsdAuthenticationException("Your ORCID (" + orcid + ") is not whitelisted.");

return origin.account(openIdInfo, provider);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

package nl.esciencecenter.rsd.authentication;

public class AuthenticationException extends RuntimeException {
public class RsdAuthenticationException extends RuntimeException {

public AuthenticationException(String message) {
public RsdAuthenticationException(String message) {
super(message);
}
}
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ services:
# dockerfile to use for build
dockerfile: Dockerfile
# update version number to correspond to frontend/package.json
image: rsd/frontend:1.6.6
image: rsd/frontend:1.9.1
environment:
# it uses values from .env file
- POSTGREST_URL
Expand Down Expand Up @@ -202,7 +202,7 @@ services:
- DUID
- DGID
# update version number to correspond to frontend/package.json
image: rsd/frontend-dev:1.6.4
image: rsd/frontend-dev:1.9.1
ports:
- "3000:3000"
- "9229:9229"
Expand Down
1 change: 1 addition & 0 deletions frontend/auth/locationCookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function saveLocationCookie() {
case '/login':
case '/logout':
case '/login/local':
case '/login/failed':
break
case '/':
// root is send to /software
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/feedback/FeedbackPanelButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Divider from '@mui/material/Divider'
import getBrowser from '~/utils/getBrowser'

export default function FeedbackPanelButton(
{feedback_email, issues_page_url, closeFeedbackPanel}: { feedback_email: string, issues_page_url:string, closeFeedbackPanel?: () => void }
{feedback_email, issues_page_url, closeFeedbackPanel}: {feedback_email: string, issues_page_url:string, closeFeedbackPanel?: () => void }
) {

const [text, setText] = useState('')
Expand Down
9 changes: 8 additions & 1 deletion frontend/components/form/AsyncAutocompleteSC.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,14 @@ export default function AsyncAutocompleteSC<T>({status, options, config,
// because search text is usually not identical to selected item
// we ignore onInputChange event when reason==='reset'
setInputValue(newInputValue)

// if user removes all input and onClear is provided
// we trigger on clear event. In addition, in freeSolo
// the icon is present that activates reason===clear
if (reason === 'input' && newInputValue === '' && onClear) {
// console.log('Call on clear event')
// issue clear attempt
onClear()
}
// we start new search if processing
// is not empty we should reset it??
if (processing !== '') {
Expand Down
41 changes: 29 additions & 12 deletions frontend/components/keyword/FindKeyword.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const props = {
onCreate:mockCreate
}

afterEach(() => {
jest.runOnlyPendingTimers()
// jest.useRealTimers()
})

// this test needs to be first to return mocked non-resolving promise
it('calls seach Fn and renders the loader', async () => {
Expand All @@ -54,9 +58,9 @@ it('calls seach Fn and renders the loader', async () => {
})

await waitFor(() => {
// validate that searchFn is called once
expect(mockSearch).toHaveBeenCalledTimes(1)
// is called with seachFor term
// validate that searchFn is called twice (first on load then on search)
expect(mockSearch).toHaveBeenCalledTimes(2)
// last called with seachFor term
expect(mockSearch).toHaveBeenCalledWith({searchFor})
// check if loader is present
const loader = screen.getByTestId('circular-loader')
Expand All @@ -80,8 +84,10 @@ it('renders component with label, help and input with role comobox', () => {
it('offer Add option when search has no results', async() => {
// prepare
jest.useFakeTimers()
// resolve with no options
mockSearch.mockResolvedValueOnce([])
// resolve with no options twice (on load and on search)
mockSearch
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
// render component
render(<FindKeyword {...props} />)

Expand Down Expand Up @@ -113,11 +119,15 @@ it('DOES NOT offer Add option when search return result that match', async () =>
const searchFor = 'test'
const searchCnt = 123
// resolve with no options
mockSearch.mockResolvedValueOnce([{
id: '123123',
keyword: searchFor,
cnt: searchCnt
}])
mockSearch
// intial call on load
.mockResolvedValueOnce([])
// search call
.mockResolvedValueOnce([{
id: '123123',
keyword: searchFor,
cnt: searchCnt
}])

// render component
render(<FindKeyword {...props} />)
Expand Down Expand Up @@ -152,7 +162,10 @@ it('calls onCreate method with string value to add new option', async() => {
// prepare
jest.useFakeTimers()
// resolve with no options
mockSearch.mockResolvedValueOnce([])
mockSearch
// intial call on load
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
// render component
render(<FindKeyword {...props} />)

Expand Down Expand Up @@ -192,7 +205,11 @@ it('calls onAdd method to add option to selection', async() => {
cnt: searchCnt
}
// resolve with no options
mockSearch.mockResolvedValueOnce([mockOption])
mockSearch
// intial call on load
.mockResolvedValueOnce([])
// search call
.mockResolvedValueOnce([mockOption])
// render component
render(<FindKeyword {...props} />)

Expand Down
Loading

0 comments on commit 34b9954

Please sign in to comment.