Skip to content

Commit 3660e84

Browse files
committed
Klargjør for innlogging og brukersesjon (hardkodet)
1 parent bcb59ce commit 3660e84

File tree

23 files changed

+535
-44
lines changed

23 files changed

+535
-44
lines changed

backend/pom.xml

+11-3
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
<exposed.version>0.44.0</exposed.version>
2727
<logback.version>1.5.0</logback.version>
2828
<kotlin-logging.version>3.0.5</kotlin-logging.version>
29+
<jose.version>9.31</jose.version>
30+
<bcrypt.version>0.10.2</bcrypt.version>
2931

3032
<!-- ===================== -->
3133
<!-- Dev dependencies -->
@@ -53,9 +55,15 @@
5355
</dependency>
5456

5557
<dependency>
56-
<groupId>org.slf4j</groupId>
57-
<artifactId>slf4j-api</artifactId>
58-
<version>${slf4j-api.version}</version>
58+
<groupId>at.favre.lib</groupId>
59+
<artifactId>bcrypt</artifactId>
60+
<version>${bcrypt.version}</version>
61+
</dependency>
62+
63+
<dependency>
64+
<groupId>com.nimbusds</groupId>
65+
<artifactId>nimbus-jose-jwt</artifactId>
66+
<version>${jose.version}</version>
5967
</dependency>
6068

6169
<dependency>

backend/src/main/kotlin/no/nais/cloud/testnais/sandbox/bachelorurlforkorter/UrlForkorterApi.kt

+19-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package no.nais.cloud.testnais.sandbox.bachelorurlforkorter
22

3+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
4+
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
35
import io.javalin.Javalin
46
import io.javalin.apibuilder.ApiBuilder.get
57
import io.javalin.apibuilder.ApiBuilder.path
@@ -9,8 +11,11 @@ import io.javalin.http.HttpResponseException
911
import io.javalin.http.HttpStatus
1012
import io.javalin.http.UnauthorizedResponse
1113
import io.javalin.http.staticfiles.Location
14+
import io.javalin.json.JavalinJackson
1215
import io.javalin.security.RouteRole
1316
import mu.KotlinLogging
17+
import no.nais.cloud.testnais.sandbox.bachelorurlforkorter.auth.Auth
18+
import no.nais.cloud.testnais.sandbox.bachelorurlforkorter.auth.Auth.validateJwtToken
1419
import no.nais.cloud.testnais.sandbox.bachelorurlforkorter.common.config.*
1520
import no.nais.cloud.testnais.sandbox.bachelorurlforkorter.common.db.DatabaseInit
1621
import org.slf4j.MDC
@@ -25,13 +30,17 @@ fun main() {
2530

2631
fun startAppServer(config: Config) {
2732
val app = Javalin.create { javalinConfig ->
33+
javalinConfig.jsonMapper(JavalinJackson(jacksonObjectMapper().registerKotlinModule()))
2834
javalinConfig.staticFiles.add("/public", Location.CLASSPATH)
2935
javalinConfig.router.apiBuilder {
3036
path("api") {
3137
post("sjekk", UrlForkorterController::sjekk, Rolle.Alle)
32-
post("forkort", UrlForkorterController::forkort, Rolle.Alle)
38+
post("forkort", UrlForkorterController::forkort, Rolle.InternNavInnlogget)
3339
post("slett", UrlForkorterController::slett, Rolle.Alle)
3440
get("hentalle", UrlForkorterController::hentAlleMedMetadata, Rolle.Alle)
41+
post("logginn", Auth::loggInn, Rolle.Alle)
42+
get("bruker", Auth::hentInnloggetBruker, Rolle.Alle)
43+
post("loggut", Auth::loggUt, Rolle.Alle)
3544
}
3645
get("{korturl}") { ctx ->
3746
if (ctx.pathParam("korturl") == "index.html") {
@@ -70,7 +79,7 @@ fun startAppServer(config: Config) {
7079

7180
app.beforeMatched { ctx ->
7281
if (ctx.path().startsWith("/api/")) {
73-
checkAccessToEndpoint(ctx, config)
82+
checkAccessToEndpoint(ctx)
7483
}
7584
}
7685

@@ -97,20 +106,21 @@ enum class Rolle : RouteRole {
97106
AdminNavInnlogget
98107
}
99108

100-
private fun checkAccessToEndpoint(ctx: Context, config: Config) {
109+
private fun checkAccessToEndpoint(ctx: Context) {
101110
when {
102111
ctx.routeRoles().isEmpty() -> {
103112
logger.error { "Manglende tilgangsstyring på endepunkt ${ctx.path()}" }
104113
throw UnauthorizedResponse()
105114
}
106115

107-
ctx.routeRoles().contains(Rolle.InternNavInnlogget) -> {
108-
val isValidUsername = config.authConfig.basicAuthUsername == ctx.basicAuthCredentials()?.username
109-
val isValidPassword = config.authConfig.basicAuthPassword.value == ctx.basicAuthCredentials()?.password
116+
ctx.routeRoles().contains(Rolle.InternNavInnlogget) || ctx.routeRoles().contains(Rolle.AdminNavInnlogget) -> {
117+
val (username, role) = validateJwtToken(ctx)
118+
?: throw UnauthorizedResponse("Invalid or expired token")
110119

111-
if (!isValidUsername || !isValidPassword) {
112-
logger.error { "Feil ved autentisering av innlogget bruker" }
113-
throw UnauthorizedResponse()
120+
ctx.attribute("username", username)
121+
122+
if (!ctx.routeRoles().contains(role)) {
123+
throw UnauthorizedResponse("Manglende autorisasjon på endepunkt")
114124
}
115125
}
116126

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package no.nais.cloud.testnais.sandbox.bachelorurlforkorter.auth
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator
4+
import com.fasterxml.jackson.annotation.JsonProperty
5+
import com.nimbusds.jose.JWSAlgorithm
6+
import com.nimbusds.jose.JWSHeader
7+
import com.nimbusds.jose.JWSVerifier
8+
import com.nimbusds.jose.crypto.MACSigner
9+
import com.nimbusds.jose.crypto.MACVerifier
10+
import com.nimbusds.jwt.JWTClaimsSet
11+
import com.nimbusds.jwt.SignedJWT
12+
import io.javalin.http.Context
13+
import io.javalin.http.UnauthorizedResponse
14+
import jakarta.servlet.http.Cookie
15+
import mu.KotlinLogging
16+
import no.nais.cloud.testnais.sandbox.bachelorurlforkorter.Rolle
17+
import java.net.URLDecoder
18+
import java.net.URLEncoder
19+
import java.nio.charset.StandardCharsets
20+
import java.util.Date
21+
22+
data class LoginRequest @JsonCreator constructor(
23+
@JsonProperty("username") val username: String,
24+
@JsonProperty("password") val password: String
25+
)
26+
27+
private val logger = KotlinLogging.logger {}
28+
29+
object Auth {
30+
31+
private const val SECRET_KEY = "U29tZXN1cGVyc2VjdXJlcGFzc3BocmFzZQ==\n"
32+
private const val TOKEN_EXPIRATION_TIME = 1000 * 60 * 60
33+
34+
fun loggInn(ctx: Context) {
35+
val loginRequest = ctx.bodyAsClass(LoginRequest::class.java)
36+
val user = UserRepository.findByUsername(loginRequest.username)
37+
?: throw UnauthorizedResponse("Invalid credentials")
38+
39+
if (!verifyPassword(loginRequest.password, user.hashedPassword)) {
40+
throw UnauthorizedResponse("Invalid credentials")
41+
}
42+
43+
val token = createJwtToken(user)
44+
45+
val encodedToken = URLEncoder.encode(token, StandardCharsets.UTF_8.toString())
46+
47+
ctx.header("Set-Cookie",
48+
"session_token=$encodedToken; Path=/; Max-Age=${TOKEN_EXPIRATION_TIME / 3600}; Secure; HttpOnly; SameSite=Strict"
49+
)
50+
51+
logger.info("Innlogging suksessfull for: {}", loginRequest.username)
52+
53+
ctx.status(204)
54+
}
55+
56+
fun hentInnloggetBruker(ctx: Context): Context {
57+
val token = ctx.cookie("session_token") ?: return ctx.status(401)
58+
59+
val decodedToken = URLDecoder.decode(token, StandardCharsets.UTF_8.toString())
60+
val signedJWT = SignedJWT.parse(decodedToken)
61+
62+
val expiration = signedJWT.jwtClaimsSet.expirationTime
63+
if (expiration.before(Date())) {
64+
return ctx.status(401)
65+
}
66+
67+
val username = signedJWT.jwtClaimsSet.subject
68+
return ctx.status(200).json(mapOf("username" to username))
69+
}
70+
71+
72+
fun loggUt(ctx: Context) {
73+
ctx.removeCookie("session_token")
74+
ctx.status(204)
75+
}
76+
77+
78+
fun createJwtToken(user: User): String {
79+
val signer = MACSigner(SECRET_KEY.toByteArray()) // HMAC signer
80+
81+
val jwtClaims = JWTClaimsSet.Builder()
82+
.subject(user.username)
83+
.claim("role", user.role.name)
84+
.issueTime(Date())
85+
.expirationTime(Date(System.currentTimeMillis() + TOKEN_EXPIRATION_TIME))
86+
.issuer("n.av")
87+
.build()
88+
89+
val signedJWT = SignedJWT(JWSHeader(JWSAlgorithm.HS256), jwtClaims)
90+
signedJWT.sign(signer)
91+
92+
return signedJWT.serialize()
93+
}
94+
95+
fun validateJwtToken(ctx: Context): Pair<String, Rolle>? {
96+
val token = ctx.cookie("session_token")?.let {
97+
URLDecoder.decode(it, StandardCharsets.UTF_8.toString())
98+
} ?: return null
99+
100+
return try {
101+
val signedJWT = SignedJWT.parse(token)
102+
val verifier: JWSVerifier = MACVerifier(SECRET_KEY.toByteArray())
103+
104+
if (!signedJWT.verify(verifier)) {
105+
return null // Invalid signature
106+
}
107+
108+
val expiration = signedJWT.jwtClaimsSet.expirationTime
109+
if (expiration.before(Date())) {
110+
return null // Token expired
111+
}
112+
113+
val username = signedJWT.jwtClaimsSet.subject
114+
val roleName = signedJWT.jwtClaimsSet.getStringClaim("role")
115+
val role = Rolle.valueOf(roleName)
116+
117+
Pair(username, role)
118+
} catch (e: Exception) {
119+
logger.warn("Feil ved validering av session token: {}", e.message)
120+
null // Invalid token
121+
}
122+
}
123+
124+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package no.nais.cloud.testnais.sandbox.bachelorurlforkorter.auth
2+
3+
import at.favre.lib.crypto.bcrypt.BCrypt
4+
5+
fun hashPassword(password: String): String {
6+
return BCrypt.withDefaults().hashToString(12, password.toCharArray()) // 12 rounds for security
7+
}
8+
9+
fun verifyPassword(inputPassword: String, storedHashedPassword: String?): Boolean {
10+
if (storedHashedPassword == null) return false
11+
return BCrypt.verifyer().verify(inputPassword.toCharArray(), storedHashedPassword).verified
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package no.nais.cloud.testnais.sandbox.bachelorurlforkorter.auth
2+
3+
import no.nais.cloud.testnais.sandbox.bachelorurlforkorter.Rolle
4+
import java.util.concurrent.ConcurrentHashMap
5+
6+
data class User(
7+
val id: String,
8+
val username: String,
9+
val hashedPassword: String?,
10+
val externalId: String?,
11+
val role: Rolle
12+
)
13+
14+
15+
object UserRepository {
16+
private val users = ConcurrentHashMap<String, User>()
17+
18+
init {
19+
val hashedPassword = hashPassword("passord123")
20+
users["admin"] = User(
21+
id = "1",
22+
username = "testadmin",
23+
hashedPassword = hashedPassword,
24+
externalId = null,
25+
role = Rolle.AdminNavInnlogget
26+
)
27+
users["bruker"] = User(
28+
id = "1",
29+
username = "testbruker",
30+
hashedPassword = hashedPassword,
31+
externalId = null,
32+
role = Rolle.InternNavInnlogget
33+
)
34+
}
35+
36+
fun findByUsername(username: String): User? = users[username]
37+
38+
fun save(user: User) {
39+
users[user.username] = user
40+
}
41+
}

frontend/index.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="favicon.png" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>url-forkorter</title>
7+
<title>n.av</title>
88
</head>
99
<body>
1010
<div id="root"></div>

frontend/src/App.css

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
:root {
2-
margin: 0;
3-
padding: 0;
4-
h1,h2,h3,h4,h5,h6 {
2+
* {
53
padding: 0;
64
margin: 0;
5+
box-sizing: border-box;
6+
}
7+
html,body {
8+
width: 100%;
9+
max-width: 100%;
710
}
811
box-sizing: border-box;
912
--theme-color: #535bf2;

frontend/src/App.tsx

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
1-
import {BrowserRouter, Route, Routes} from "react-router-dom";
1+
import {BrowserRouter, Navigate, Route, Routes} from "react-router-dom";
22
import LandingPage from "./pages/LandingPage.tsx"
33
import "./App.css"
4+
import Header from "./components/Header/Header.tsx";
5+
import {useAuth} from "./util/hooks/useAuth.ts";
6+
import {ReactNode} from "react";
47

58
export default function App() {
69

710
return (
811
<BrowserRouter>
12+
<Header/>
913
<Routes>
1014
<Route path={"/"} element={<LandingPage/>}/>
11-
<Route path={"/user/:id"} element={<div></div>}></Route>
15+
<Route path={"/user/:id"} element={
16+
<ProtectedRoute>
17+
<div></div>
18+
</ProtectedRoute>
19+
}></Route>
1220
</Routes>
1321
</BrowserRouter>
1422
)
23+
}
24+
25+
function ProtectedRoute({children}: { children: ReactNode }) {
26+
const {isLoggedIn} = useAuth();
27+
return isLoggedIn ? children : <Navigate to="/"/>;
1528
}
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {DisplayUser, HeaderContainer, LogoText} from "./header.style.ts";
2+
import Button from "../shared/Button/Button.tsx";
3+
import Modal from "../shared/Modal/Modal.tsx";
4+
import {useState} from "react";
5+
import LoginForm from "../LoginForm/LoginForm.tsx";
6+
import {useAuth} from "../../util/hooks/useAuth.ts";
7+
8+
export default function Header() {
9+
const [showLogin, setShowLogin] = useState(false);
10+
const {isLoggedIn, user, logout} = useAuth();
11+
12+
return (
13+
<HeaderContainer>
14+
<LogoText>n.av</LogoText>
15+
{isLoggedIn ? (
16+
<div style={{display: "flex", alignItems: "center"}}>
17+
<DisplayUser>Velkommen, {user}!</DisplayUser>
18+
<Button text="Logg ut" onClick={logout}/>
19+
</div>
20+
) : (
21+
<>
22+
<Button text="Logg inn" onClick={() => setShowLogin(true)}/>
23+
<Modal showModal={showLogin} setShowModal={setShowLogin}>
24+
<LoginForm onLogin={() => setShowLogin(false)}/>
25+
</Modal>
26+
</>
27+
)}
28+
</HeaderContainer>
29+
)
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import styled from "styled-components";
2+
3+
export const HeaderContainer = styled.header`
4+
display: flex;
5+
align-items: center;
6+
justify-content: space-between;
7+
padding: 10px 20px;
8+
width: 100%;
9+
background: #f8f9fa;
10+
`;
11+
12+
export const LogoText = styled.h1`
13+
color: var(--theme-color);
14+
font-size: 32px;
15+
`
16+
17+
export const DisplayUser = styled.p`
18+
margin: 0 15px;
19+
`

0 commit comments

Comments
 (0)