Skip to content

Commit

Permalink
Add AuthContext and user API (#115)
Browse files Browse the repository at this point in the history
  • Loading branch information
domi-b authored Nov 28, 2023
2 parents a918ce3 + 6efe75c commit 07a340a
Show file tree
Hide file tree
Showing 10 changed files with 291 additions and 38 deletions.
4 changes: 2 additions & 2 deletions src/GeoCop.Api/ContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ namespace GeoCop.Api;
internal static class ContextExtensions
{
internal const string UserIdClaim = "oid";
private const string NameClaim = "name";
private const string EmailClaim = "email";
internal const string NameClaim = "name";
internal const string EmailClaim = "email";

/// <summary>
/// Retreives the user that matches the provided principal from the database.
Expand Down
36 changes: 36 additions & 0 deletions src/GeoCop.Api/Controllers/UserController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using GeoCop.Api.Authorization;
using GeoCop.Api.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace GeoCop.Api.Controllers;

/// <summary>
/// Controller for user information.
/// </summary>
[Authorize(Policy = GeocopPolicies.User)]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class UserController : ControllerBase
{
private readonly Context context;

/// <summary>
/// Initializes a new instance of the <see cref="UserController"/> class.
/// </summary>
/// <param name="context">The database context.</param>
public UserController(Context context)
{
this.context = context;
}

/// <summary>
/// Gets the current user information.
/// </summary>
/// <returns>The <see cref="User"/> that is currently logged in.</returns>
[HttpGet]
public async Task<User?> GetAsync()
{
return await context.GetUserByPrincipalAsync(User);
}
}
6 changes: 5 additions & 1 deletion src/GeoCop.Api/Models/User.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace GeoCop.Api.Models;
using System.Text.Json.Serialization;

namespace GeoCop.Api.Models;

/// <summary>
/// A person that is allowed to view or declare deliveries.
Expand All @@ -8,11 +10,13 @@ public class User
/// <summary>
/// The unique identifier for the user.
/// </summary>
[JsonIgnore]
public int Id { get; set; }

/// <summary>
/// The unique identifier for the user in the authentication system.
/// </summary>
[JsonIgnore]
public string AuthIdentifier { get; set; } = string.Empty;

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/GeoCop.Frontend/public/client-settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"navigateToLoginRequestUrl": false
},
"cache": {
"cacheLocation": "sessionStorage",
"cacheLocation": "localStorage",
"storeAuthStateInCookie": false
}
},
Expand Down
14 changes: 5 additions & 9 deletions src/GeoCop.Frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { PublicClientApplication } from "@azure/msal-browser";
import { AuthenticatedTemplate, UnauthenticatedTemplate, MsalProvider } from "@azure/msal-react";
import { useEffect, useMemo, useState } from "react";
import { AuthenticatedTemplate, UnauthenticatedTemplate } from "@azure/msal-react";
import { useEffect, useState } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
import BannerContent from "./BannerContent";
import Footer from "./Footer";
Expand All @@ -9,6 +8,7 @@ import Home from "./pages/home/Home";
import Admin from "./pages/admin/Admin";
import ModalContent from "./ModalContent";
import "./app.css";
import { AuthProvider } from "./contexts/AuthContext";

export const App = () => {
const [modalContent, setModalContent] = useState(false);
Expand Down Expand Up @@ -82,12 +82,8 @@ export const App = () => {
const openModalContent = (content, type) =>
setModalContent(content) & setModalContentType(type) & setShowModalContent(true);

const msalInstance = useMemo(() => {
return new PublicClientApplication(clientSettings?.oauth ?? {});
}, [clientSettings]);

return (
<MsalProvider instance={msalInstance}>
<AuthProvider authScopes={clientSettings?.authScopes} oauth={clientSettings?.oauth}>
<div className="app">
<Router>
<Header clientSettings={clientSettings} />
Expand Down Expand Up @@ -139,7 +135,7 @@ export const App = () => {
<BannerContent className="banner" content={bannerContent} onHide={() => setShowBannerContent(false)} />
)}
</div>
</MsalProvider>
</AuthProvider>
);
};

Expand Down
29 changes: 4 additions & 25 deletions src/GeoCop.Frontend/src/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,10 @@
import { Button, Navbar, Nav, Container } from "react-bootstrap";
import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from "@azure/msal-react";
import { AuthenticatedTemplate, UnauthenticatedTemplate } from "@azure/msal-react";
import { NavLink } from "react-router-dom";
import { useAuth } from "./contexts/auth";

export const Header = ({ clientSettings }) => {
const { instance } = useMsal();
const activeAccount = instance.getActiveAccount();

async function login() {
try {
const result = await instance.loginPopup({
scopes: clientSettings?.authScopes,
});
instance.setActiveAccount(result.account);
document.cookie = `geocop.auth=${result.idToken};Path=/;Secure`;
} catch (error) {
console.warn(error);
}
}

async function logout() {
try {
await instance.logoutPopup();
document.cookie = "geocop.auth=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure";
} catch (error) {
console.warn(error);
}
}
const { user, login, logout } = useAuth();

return (
<header>
Expand Down Expand Up @@ -74,7 +53,7 @@ export const Header = ({ clientSettings }) => {
</div>
<div className="navbar-info-container">
<AuthenticatedTemplate>
<div className="user-info">Angemeldet als {activeAccount?.name}</div>
<div className="user-info">Angemeldet als {user?.name}</div>
</AuthenticatedTemplate>
</div>
</Navbar.Collapse>
Expand Down
107 changes: 107 additions & 0 deletions src/GeoCop.Frontend/src/contexts/AuthContext.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { PublicClientApplication } from "@azure/msal-browser";
import { MsalProvider } from "@azure/msal-react";
import { createContext, useCallback, useState, useMemo, useEffect, useRef } from "react";

const authDefault = {
user: undefined,
login: () => {},
logout: () => {},
};

export const AuthContext = createContext(authDefault);

export const AuthProvider = ({ children, authScopes, oauth }) => {
const msalInstance = useMemo(() => {
return new PublicClientApplication(oauth ?? {});
}, [oauth]);

const [user, setUser] = useState();
const loginSilentIntervalRef = useRef();

const fetchUserInfo = useCallback(async () => {
const userResult = await fetch("/api/v1/user");
if (!userResult.ok) throw new Error(userResult.statusText);

const userJson = await userResult.json();
setUser({ name: userJson.fullName, isAdmin: userJson.isAdmin });
}, [setUser]);

const loginCompleted = useCallback(
async (idToken) => {
document.cookie = `geocop.auth=${idToken};Path=/;Secure`;
await fetchUserInfo();
},
[fetchUserInfo],
);

const logoutCompleted = useCallback(() => {
msalInstance.setActiveAccount(null);
clearInterval(loginSilentIntervalRef.current);
document.cookie = "geocop.auth=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure";
setUser(undefined);
}, [setUser, msalInstance]);

const loginSilent = useCallback(async () => {
try {
await msalInstance.initialize();
const result = await msalInstance.acquireTokenSilent({
scopes: authScopes,
});
loginCompleted(result.idToken);
} catch (error) {
console.warn("Failed to refresh authentication.", error);
logoutCompleted();
}
}, [msalInstance, authScopes, loginCompleted, logoutCompleted]);

const setRefreshTokenInterval = useCallback(() => {
clearInterval(loginSilentIntervalRef.current);
loginSilentIntervalRef.current = setInterval(loginSilent, 1000 * 60 * 5);
}, [loginSilent]);

// Fetch user info after reload
const activeAccount = msalInstance.getActiveAccount();
const hasActiveAccount = activeAccount !== null;
useEffect(() => {
if (hasActiveAccount && !user) {
loginSilent();
setRefreshTokenInterval();
}
}, [hasActiveAccount, user, loginSilent, setRefreshTokenInterval]);

async function login() {
try {
const result = await msalInstance.loginPopup({
scopes: authScopes,
});
msalInstance.setActiveAccount(result.account);
loginCompleted(result.idToken);
setRefreshTokenInterval();
} catch (error) {
console.warn(error);
}
}

async function logout() {
try {
await msalInstance.logoutPopup();
logoutCompleted();
} catch (error) {
console.warn(error);
}
}

return (
<MsalProvider instance={msalInstance}>
<AuthContext.Provider
value={{
user,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
</MsalProvider>
);
};
4 changes: 4 additions & 0 deletions src/GeoCop.Frontend/src/contexts/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { useContext } from "react";
import { AuthContext } from "./AuthContext";

export const useAuth = () => useContext(AuthContext);
25 changes: 25 additions & 0 deletions tests/GeoCop.Api.Test/ControllerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using GeoCop.Api.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using System.Security.Claims;

namespace GeoCop.Api.Test;

internal static class ControllerExtensions
{
public static Mock<HttpContext> SetupTestUser(this ControllerBase controller, User user)
{
var httpContextMock = new Mock<HttpContext>();
controller.ControllerContext.HttpContext = httpContextMock.Object;

var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ContextExtensions.UserIdClaim, user.AuthIdentifier),
new Claim(ContextExtensions.NameClaim, user.FullName),
new Claim(ContextExtensions.EmailClaim, user.Email),
}));
httpContextMock.SetupGet(c => c.User).Returns(principal);
return httpContextMock;
}
}
Loading

0 comments on commit 07a340a

Please sign in to comment.