Skip to content

Commit b76b47f

Browse files
committed
Create login audit table
1 parent 2fc49da commit b76b47f

File tree

6 files changed

+181
-1
lines changed

6 files changed

+181
-1
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ ADD https://raw.githubusercontent.com/HSLdevcom/jore4-tools/main/docker/read-sec
2525
# copy over compiled jar
2626
COPY --from=builder /build/target/*.jar /usr/src/jore4-auth/auth-backend.jar
2727

28-
# read docker secrets into environment varaibles and run application
28+
# read docker secrets into environment variables and run application
2929
CMD /bin/bash -c "source /app/scripts/read-secrets.sh && java -jar /usr/src/jore4-auth/auth-backend.jar"
3030

3131
HEALTHCHECK --interval=1m --timeout=5s \
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package fi.hsl.jore4.auth.audit
2+
3+
import jakarta.persistence.Column
4+
import jakarta.persistence.Entity
5+
import jakarta.persistence.GeneratedValue
6+
import jakarta.persistence.GenerationType
7+
import jakarta.persistence.Id
8+
import jakarta.persistence.Table
9+
import java.time.Instant
10+
11+
/**
12+
* Entity representing a login record.
13+
*/
14+
@Entity
15+
@Table(name = "login_audit")
16+
data class LoginAudit(
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
val id: Long? = null,
20+
@Column(name = "user_id", nullable = false)
21+
val userId: String,
22+
@Column(name = "user_name")
23+
val userName: String?,
24+
@Column(name = "login_timestamp", nullable = false)
25+
val loginTimestamp: Instant = Instant.now()
26+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package fi.hsl.jore4.auth.audit
2+
3+
import org.springframework.data.jpa.repository.JpaRepository
4+
import org.springframework.stereotype.Repository
5+
6+
/**
7+
* Repository for login records.
8+
*/
9+
@Repository
10+
interface LoginAuditRepository : JpaRepository<LoginAudit, Long>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package fi.hsl.jore4.auth.audit
2+
3+
import org.slf4j.Logger
4+
import org.slf4j.LoggerFactory
5+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
6+
import org.springframework.stereotype.Service
7+
import org.springframework.transaction.annotation.Transactional
8+
9+
/**
10+
* Service for logging login events.
11+
* Only enabled when JPA is available.
12+
*/
13+
@Service
14+
@ConditionalOnBean(LoginAuditRepository::class)
15+
open class LoginAuditService(
16+
private val loginAuditRepository: LoginAuditRepository
17+
) {
18+
companion object {
19+
private val LOGGER: Logger = LoggerFactory.getLogger(LoginAuditService::class.java)
20+
}
21+
22+
/**
23+
* Record a login event for the specified user.
24+
*/
25+
@Transactional
26+
open fun recordLogin(
27+
userId: String,
28+
userName: String?
29+
) {
30+
try {
31+
val auditRecord =
32+
LoginAudit(
33+
userId = userId,
34+
userName = userName
35+
)
36+
37+
loginAuditRepository.save(auditRecord)
38+
39+
LOGGER.info("Recorded login for user: userId={}, userName={}", userId, userName)
40+
} catch (e: Exception) {
41+
// Log the error but don't fail the login process
42+
LOGGER.error("Failed to record login audit for user: userId={}, userName={}", userId, userName, e)
43+
}
44+
}
45+
}

src/main/kotlin/fi/hsl/jore4/auth/oidc/OIDCCodeExchangeService.kt

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package fi.hsl.jore4.auth.oidc
22

3+
import com.fasterxml.jackson.databind.ObjectMapper
34
import com.nimbusds.oauth2.sdk.AuthorizationCode
45
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant
56
import com.nimbusds.oauth2.sdk.Scope
@@ -10,9 +11,13 @@ import com.nimbusds.oauth2.sdk.id.ClientID
1011
import com.nimbusds.oauth2.sdk.id.State
1112
import com.nimbusds.openid.connect.sdk.OIDCTokenResponse
1213
import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser
14+
import fi.hsl.jore4.auth.audit.LoginAuditService
1315
import jakarta.servlet.http.HttpSession
16+
import okhttp3.OkHttpClient
17+
import okhttp3.Request
1418
import org.slf4j.Logger
1519
import org.slf4j.LoggerFactory
20+
import org.springframework.beans.factory.annotation.Autowired
1621
import org.springframework.beans.factory.annotation.Value
1722
import org.springframework.stereotype.Service
1823
import java.net.URI
@@ -28,10 +33,17 @@ open class OIDCCodeExchangeService(
2833
private val verificationService: TokenVerificationService,
2934
@Value("\${loginpage.url}") private val loginPageUrl: String
3035
) {
36+
@Autowired(required = false)
37+
private var loginAuditService: LoginAuditService? = null
38+
3139
companion object {
3240
private val LOGGER: Logger = LoggerFactory.getLogger(OIDCCodeExchangeService::class.java)
41+
private const val SUB_CLAIM = "sub"
42+
private const val NAME_CLAIM = "name"
3343
}
3444

45+
private val httpClient = OkHttpClient.Builder().build()
46+
3547
/**
3648
* Exchange the given authorization {@param code} for an access and refresh token.
3749
*
@@ -78,6 +90,7 @@ open class OIDCCodeExchangeService(
7890
// get the access token and refresh token
7991
val accessToken = successResponse.oidcTokens.accessToken
8092
val refreshToken = successResponse.oidcTokens.refreshToken
93+
val idToken = successResponse.oidcTokens.idToken
8194

8295
// verify token authenticity and validity if not using Entra, as it uses an unverifiable internal token
8396
// See https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#validate-tokens
@@ -87,11 +100,74 @@ open class OIDCCodeExchangeService(
87100

88101
session.setAttribute(SessionKeys.USER_TOKEN_SET_KEY, UserTokenSet(accessToken, refreshToken))
89102

103+
// Record the login event in the audit log
104+
try {
105+
loginAuditService?.let {
106+
val userId = idToken.jwtClaimsSet.subject
107+
val userName = fetchUserNameFromUserInfo(accessToken.value, userId)
108+
it.recordLogin(userId, userName)
109+
}
110+
} catch (e: Exception) {
111+
LOGGER.warn("Could not record login audit", e)
112+
}
113+
90114
// redirect the user to the login page URL
91115
val redirectUri = URI.create(loginPageUrl)
92116

93117
LOGGER.debug("Created redirect URI: {}", redirectUri)
94118

95119
return redirectUri
96120
}
121+
122+
/**
123+
* Fetch the user's name from the OIDC UserInfo endpoint.
124+
*/
125+
private fun fetchUserNameFromUserInfo(
126+
accessTokenValue: String,
127+
expectedSub: String
128+
): String? =
129+
runCatching {
130+
val request =
131+
Request
132+
.Builder()
133+
.addHeader("Authorization", "Bearer $accessTokenValue")
134+
.addHeader("Accept", "application/json")
135+
.get()
136+
.url(oidcProviderMetadataSupplier.providerMetadata.userInfoEndpointURI.toURL())
137+
.build()
138+
139+
httpClient
140+
.newCall(request)
141+
.execute()
142+
.use { response ->
143+
if (!response.isSuccessful) {
144+
LOGGER.warn("Failed to fetch UserInfo, status: {}", response.code)
145+
return null
146+
}
147+
148+
val responseBody =
149+
response.body?.string() ?: run {
150+
LOGGER.warn("UserInfo response body is null")
151+
return null
152+
}
153+
154+
val result = ObjectMapper().readValue(responseBody, Map::class.java) as Map<*, *>
155+
156+
// Verify that the sub claim matches the ID token's sub
157+
val userInfoSub = result[SUB_CLAIM]?.toString()
158+
if (userInfoSub != expectedSub) {
159+
LOGGER.error(
160+
"UserInfo sub claim '{}' does not match ID token sub '{}'.",
161+
userInfoSub,
162+
expectedSub
163+
)
164+
return null
165+
}
166+
167+
result[NAME_CLAIM]?.toString()
168+
}
169+
}.getOrElse { e ->
170+
LOGGER.warn("Error fetching user name from UserInfo endpoint", e)
171+
null
172+
}
97173
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Create login_audit table for tracking login events
2+
CREATE TABLE IF NOT EXISTS login_audit (
3+
id BIGSERIAL PRIMARY KEY,
4+
user_id TEXT NOT NULL,
5+
user_name TEXT,
6+
login_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
7+
);
8+
9+
CREATE INDEX IF NOT EXISTS idx_login_audit_user_id ON login_audit(user_id);
10+
CREATE INDEX IF NOT EXISTS idx_login_audit_timestamp ON login_audit(login_timestamp);
11+
12+
-- Grant permissions to the database user
13+
DO $$
14+
BEGIN
15+
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'dbauth') THEN
16+
GRANT SELECT, INSERT ON login_audit TO dbauth;
17+
GRANT USAGE, SELECT ON SEQUENCE login_audit_id_seq TO dbauth;
18+
END IF;
19+
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'dbhasura') THEN
20+
GRANT SELECT ON login_audit TO dbhasura;
21+
GRANT USAGE, SELECT ON SEQUENCE login_audit_id_seq TO dbhasura;
22+
END IF;
23+
END $$;

0 commit comments

Comments
 (0)