Skip to content

Commit

Permalink
Add whoami endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
Isti01 committed Oct 2, 2024
1 parent 40a1587 commit 2f95f40
Show file tree
Hide file tree
Showing 21 changed files with 242 additions and 81 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package hu.bme.sch.cmsch.component.app

import com.fasterxml.jackson.annotation.JsonView
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import hu.bme.sch.cmsch.component.ComponentHandlerService
import hu.bme.sch.cmsch.component.countdown.CountdownComponent
import hu.bme.sch.cmsch.dto.FullDetails
import hu.bme.sch.cmsch.model.RoleType
import hu.bme.sch.cmsch.service.TimeService
import hu.bme.sch.cmsch.util.getUserEntityFromDatabaseOrNull
import hu.bme.sch.cmsch.util.getUserOrNull
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatusCode
import org.springframework.http.ResponseEntity
Expand All @@ -25,7 +31,8 @@ class ApplicationApiController(
private val componentHandlerService: ComponentHandlerService,
private val countdownComponent: Optional<CountdownComponent>,
private val clock: TimeService,
private val stylingComponent: StylingComponent
private val stylingComponent: StylingComponent,
private val applicationService: ApplicationService
) {

private val componentWriter = ObjectMapper().writerFor(object : TypeReference<Map<String, Map<String, Any>>>() {})
Expand Down Expand Up @@ -59,6 +66,29 @@ class ApplicationApiController(
)
}

@JsonView(FullDetails::class)
@GetMapping("/whoami")
@Operation(summary = "Show authentication info")
@ApiResponses(
value = [
ApiResponse(
responseCode = "200", description = "The user is authenticated and data is provided or " +
"no valid token present (in this case the loggedIn is false)"
),
ApiResponse(
responseCode = "403", description = "Valid token is provided but the endpoint is not available " +
"for the role that the user have"
)
]
)
fun profile(auth: Authentication?): ResponseEntity<UserAuthInfoView> {
val jwtUser = auth?.getUserOrNull()
?: return ResponseEntity.ok(UserAuthInfoView(authState = AuthState.LOGGED_OUT))

val actualUser = auth.getUserEntityFromDatabaseOrNull()
return ResponseEntity.ok(applicationService.getUserAuthInfo(jwtUser, actualUser))
}

private fun appComponentFields() =
mapOf(applicationComponent.defaultComponent.property to applicationComponent.defaultComponent.getValue())

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package hu.bme.sch.cmsch.component.app

import hu.bme.sch.cmsch.component.login.CmschUser
import hu.bme.sch.cmsch.model.UserEntity
import org.springframework.stereotype.Service

@Service
class ApplicationService {

fun getUserAuthInfo(jwtUser: CmschUser, actualUser: UserEntity?): UserAuthInfoView {
if (actualUser == null || isAuthenticationExpired(jwtUser, actualUser))
return UserAuthInfoView(authState = AuthState.EXPIRED)

return UserAuthInfoView(
authState = AuthState.LOGGED_IN,
id = actualUser.id,
internalId = actualUser.internalId,
role = actualUser.role,
permissionsAsList = actualUser.permissionsAsList,
userName = actualUser.userName,
groupId = actualUser.groupId,
groupName = actualUser.groupName,
)
}

fun isAuthenticationExpired(jwtUser: CmschUser, actualUser: UserEntity): Boolean {
if (jwtUser.id != actualUser.id) return true
if (jwtUser.internalId != actualUser.internalId) return true
if (jwtUser.role != actualUser.role) return true
if (jwtUser.permissionsAsList != actualUser.permissionsAsList) return true
if (jwtUser.userName != actualUser.userName) return true
if (jwtUser.groupId != actualUser.groupId) return true
if (jwtUser.groupName != actualUser.groupName) return true

return false
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package hu.bme.sch.cmsch.component.app

import com.fasterxml.jackson.annotation.JsonView
import hu.bme.sch.cmsch.dto.FullDetails
import hu.bme.sch.cmsch.model.RoleType

enum class AuthState {
EXPIRED,
LOGGED_IN,
LOGGED_OUT
}


data class UserAuthInfoView(
@field:JsonView(FullDetails::class)
val authState: AuthState = AuthState.LOGGED_OUT,

@field:JsonView(FullDetails::class)
val id: Int? = null,

@field:JsonView(FullDetails::class)
val internalId: String? = null,

@field:JsonView(FullDetails::class)
val role: RoleType? = null,

@field:JsonView(FullDetails::class)
val permissionsAsList: List<String>? = null,

@field:JsonView(FullDetails::class)
val userName: String? = null,

@field:JsonView(FullDetails::class)
val groupId: Int? = null,

@field:JsonView(FullDetails::class)
val groupName: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class AuthschLoginController(

@GetMapping("/control/open-site")
fun openSite(auth: Authentication?, response: HttpServletResponse): String {
if (auth != null && startupPropertyConfig.jwtEnabled) {
if (auth != null) {
if (auth.principal !is CmschUser) {
log.error("User is not CmschUser {} {}", auth, auth.javaClass.simpleName)
return "redirect:${applicationComponent.siteUrl.getValue()}?error=cannot-generate-jwt"
Expand All @@ -93,8 +93,6 @@ class AuthschLoginController(
fun refreshToken(auth: Authentication?, response: HttpServletResponse): ResponseEntity<String> {
if (auth == null)
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
if (!startupPropertyConfig.jwtEnabled)
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("JWT not enabled")

response.addCookie(createJwtCookie(jwtTokenProvider.refreshToken(auth)))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ data class StartupPropertyConfig @ConstructorBinding constructor(
val zoneId: String,

// JWT
val jwtEnabled: Boolean,
val secretKey: String,
val sessionValidityInMilliseconds: Long,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ class InstanceInfoDashboard(
listOf("Audit log location", startupPropertyConfig.auditLog),
listOf("Time zone id", startupPropertyConfig.zoneId),
listOf("Mailgun token length", startupPropertyConfig.mailgunToken.length.toString()),
listOf("JWT enabled", startupPropertyConfig.jwtEnabled.toString()),
listOf("Session validity (ms)", startupPropertyConfig.sessionValidityInMilliseconds.toString()),
listOf("Increased session time (ms)", startupPropertyConfig.increasedSessionTime.toString()),
listOf("Profile QR enabled", startupPropertyConfig.profileQrEnabled.toString()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ hu.bme.sch.cmsch.startup.external=./temp/cdn/
hu.bme.sch.cmsch.startup.audit-log=./temp/audit/

# JWT
hu.bme.sch.cmsch.startup.jwt-enabled=true
hu.bme.sch.cmsch.startup.secret-key=change_this_in_production
hu.bme.sch.cmsch.startup.session-validity-in-milliseconds=172800000

Expand Down Expand Up @@ -44,4 +43,3 @@ hu.bme.sch.cmsch.token.nova-out=test-out

hu.bme.sch.cmsch.indulasch.token=none
server.servlet.session.timeout=3600

1 change: 0 additions & 1 deletion backend/src/main/resources/config/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ hu.bme.sch.cmsch.startup.external=./temp/cdn/
# caching is disabled when set to 0
hu.bme.sch.cmsch.startup.cdnCacheMaxAge=86400
hu.bme.sch.cmsch.startup.audit-log=./temp/audit/
hu.bme.sch.cmsch.startup.jwt-enabled=true
hu.bme.sch.cmsch.startup.secret-key=change_this_in_production__this_si_some_padding
hu.bme.sch.cmsch.google.service-account-key=the_contents_of_service-account.json_from_firebase_console
server.error.path=/error
Expand Down
37 changes: 22 additions & 15 deletions frontend/src/api/contexts/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,47 @@
import { createContext, PropsWithChildren } from 'react'
import { createContext, PropsWithChildren, useEffect } from 'react'
import { API_BASE_URL } from '../../../util/configs/environment.config'
import { ProfileView } from '../../../util/views/profile.view'
import { useProfileQuery } from '../../hooks/profile/useProfileQuery'
import { useAuthInfo } from '../../hooks/auth/useAuthInfo.ts'
import { AuthState, UserAuthInfoView } from '../../../util/views/authInfo.view.ts'
import { useTokenRefresh } from '../../hooks/useTokenRefresh.ts'

export type AuthContextType = {
isLoggedIn: boolean
profile: ProfileView | undefined
profileLoading: boolean
profileError: Error | null
authInfo: UserAuthInfoView | undefined
authInfoLoading: boolean
authInfoError: Error | null
onLogout: () => void
refetch: () => void
}

export const AuthContext = createContext<AuthContextType>({
isLoggedIn: false,
profile: undefined,
profileLoading: false,
profileError: null,
authInfo: undefined,
authInfoLoading: false,
authInfoError: null,
onLogout: () => {},
refetch: () => {}
})

export const AuthProvider = ({ children }: PropsWithChildren) => {
const { isLoading: authInfoLoading, data: authInfo, error: authInfoError, refetch } = useAuthInfo()
const onLogout = async () => {
window.location.href = `${API_BASE_URL}/control/logout`
}

const { isLoading: profileLoading, data: profile, error: profileError, refetch } = useProfileQuery()
const tokenRefresh = useTokenRefresh()
const authState = authInfo?.authState
useEffect(() => {
if (authState === AuthState.EXPIRED) {
tokenRefresh.mutate()
}
}, [authState])

return (
<AuthContext.Provider
value={{
isLoggedIn: profile?.loggedIn || false,
profileLoading,
profile,
profileError,
isLoggedIn: authInfo?.authState === AuthState.LOGGED_IN,
authInfoLoading,
authInfo,
authInfoError,
onLogout,
refetch
}}
Expand Down
58 changes: 15 additions & 43 deletions frontend/src/api/contexts/config/ConfigContext.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,28 @@
import { Box, Button, ButtonGroup, Center, Heading, Text, useColorModeValue, VStack } from '@chakra-ui/react'
import { createContext, PropsWithChildren, useContext } from 'react'
import { Helmet } from 'react-helmet-async'
import { Loading } from '../../../common-components/Loading'
import { INITIAL_BG_IMAGE } from '../../../util/configs/environment.config'
import { l } from '../../../util/language'
import { useConfigQuery } from '../../hooks/config/useConfigQuery'
import { ConfigDto } from './types'
import { KirDevLogo } from '../../../assets/kir-dev-logo'
import { LoadingView } from '../../../util/LoadingView.tsx'
import { l } from '../../../util/language.ts'

export const ConfigContext = createContext<ConfigDto | undefined>(undefined)

export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data, isLoading, error, refetch } = useConfigQuery((err) =>
console.error('[ERROR] at ConfigProvider', JSON.stringify(err, null, 2))
)
const bg = useColorModeValue('white', 'gray.900')
if (isLoading)
return (
<Center flexDirection="column" h="100vh" backgroundImage={INITIAL_BG_IMAGE} backgroundPosition="center" backgroundSize="cover">
<VStack p={5} borderRadius={5} bg={bg}>
<Loading />
<Box w={40} maxH={40} my={3}>
<KirDevLogo />
</Box>
</VStack>
</Center>
)
if (error) {
const is500Status = Math.floor(Number(error?.response?.status) / 100) === 5
return (
<Center flexDirection="column" h="100vh" backgroundImage={INITIAL_BG_IMAGE} backgroundPosition="center" backgroundSize="cover">
<Helmet title={l('error-page-helmet')} />
<VStack spacing={5} p={5} borderRadius={5} bg={bg}>
<Heading textAlign="center">{is500Status ? l('error-service-unavailable-title') : l('error-page-title')}</Heading>
<Text textAlign="center" color="gray.500" marginTop={4} maxW={96}>
{is500Status ? l('error-service-unavailable') : l('error-connection-unsuccessful')}
</Text>
<ButtonGroup justifyContent="center" marginTop={4}>
<Button
colorScheme="brand"
onClick={() => {
refetch()
}}
>
Újra
</Button>
</ButtonGroup>
</VStack>
</Center>
)
}
return <ConfigContext.Provider value={data}>{children}</ConfigContext.Provider>

const is500Status = Math.floor(Number(error?.response?.status) / 100) === 5
return (
<LoadingView
isLoading={isLoading}
hasError={!!error}
errorAction={refetch}
errorMessage={is500Status ? l('error-service-unavailable') : l('error-connection-unsuccessful')}
errorTitle={is500Status ? l('error-service-unavailable-title') : l('error-page-title')}
>
<ConfigContext.Provider value={data}>{children}</ConfigContext.Provider>
</LoadingView>
)
}

export const useConfigContext = () => {
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/api/hooks/auth/useAuthInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import axios from 'axios'
import { useQuery } from 'react-query'
import { QueryKeys } from '../queryKeys'
import { ApiPaths } from '../../../util/paths'
import { UserAuthInfoView } from '../../../util/views/authInfo.view.ts'

export const useAuthInfo = () => {
return useQuery<UserAuthInfoView, Error>(QueryKeys.WHO_AM_I, async () => {
const response = await axios.get<UserAuthInfoView>(ApiPaths.WHO_AM_I)
return response.data
})
}
1 change: 1 addition & 0 deletions frontend/src/api/hooks/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export enum QueryKeys {
WARNING = 'WARNING',
NEWS = 'NEWS',
USER = 'USER',
WHO_AM_I = 'WHO_AM_I',
RACE = 'RACE',
FREESTYLE_RACE = 'FREESTYLE_RACE',
RIDDLE_HINT = 'RIDDLE_HINT',
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/common-components/ComponentUnavailable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import { LoginRequired } from './LoginRequired'

export function ComponentUnavailable() {
const { sendMessage } = useServiceContext()
const { isLoggedIn, profileLoading } = useAuthContext()
const { isLoggedIn, authInfoLoading } = useAuthContext()
useEffect(() => {
if (isLoggedIn) sendMessage(l('component-unavailable'))
}, [])
if (!isLoggedIn) {
if (profileLoading) return <LoadingPage />
if (authInfoLoading) return <LoadingPage />
return <LoginRequired />
}
return <Navigate to={AbsolutePaths.ERROR} />
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/common-components/layout/CmschPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ interface CmschPageProps extends CmschContainerProps {
}

export const CmschPage = ({ loginRequired, children, minRole, ...props }: CmschPageProps) => {
const { profile, profileLoading, isLoggedIn } = useAuthContext()
const { authInfo, authInfoLoading, isLoggedIn } = useAuthContext()
if (loginRequired) {
if (profileLoading) return <LoadingPage />
if (authInfoLoading) return <LoadingPage />
if (!isLoggedIn) return <LoginRequired />
}
if (minRole && minRole > 0) {
if (!profile?.role || RoleType[profile?.role] < minRole) {
if (!authInfo?.role || RoleType[authInfo?.role] < minRole) {
return <Navigate to="/" replace />
}
}
Expand Down
Loading

0 comments on commit 2f95f40

Please sign in to comment.