diff --git a/README.md b/README.md index 2d581b9f2..86aeabc73 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ spring.security.oauth2.client.registration.google.client-secret= cmsch.website-default-url=http://:8080/ hu.bme.sch.cmsch.login.googleAdminAddresses= +hu.bme.sch.cmsch.login.authschAdminAddresses=@sch.bme.hu logging.level.web=DEBUG ``` diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/admission/AdmissionService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/admission/AdmissionService.kt index 73bc376f0..59fa9e0f7 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/admission/AdmissionService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/admission/AdmissionService.kt @@ -107,9 +107,12 @@ class AdmissionService( return ticketRepository.findTop1ByQrCodeAndUseCmschIdTrue(cmschId).firstOrNull() } else { - val ticketByEmail = ticketRepository.findTop1ByEmailAndUseCmschIdFalse(user.email).firstOrNull() - if (user.email.isNotEmpty() && ticketByEmail != null) { - return ticketByEmail + val email = user.email + if (!email.isNullOrEmpty()) { + val ticketByEmail = ticketRepository.findTop1ByEmailAndUseCmschIdFalse(email).firstOrNull() + if (ticketByEmail != null) { + return ticketByEmail + } } return ticketRepository.findTop1ByQrCodeAndUseCmschIdTrue(cmschId).firstOrNull() diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/challenge/ChallengeController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/challenge/ChallengeController.kt index 589b70725..1d1353966 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/challenge/ChallengeController.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/challenge/ChallengeController.kt @@ -142,4 +142,4 @@ class ChallengeController( } private fun mapUsername(it: UserEntity) = - "${it.fullNameWithAlias} | ${it.id} | [${it.provider.firstOrNull() ?: 'n'}] ${it.email}" + "${it.fullNameWithAlias} | ${it.id} | [${it.provider.firstOrNull() ?: 'n'}] ${it.email ?: ""}" diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/form/FormService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/form/FormService.kt index 7cdd7c0bd..fd67c6407 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/form/FormService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/form/FormService.kt @@ -426,7 +426,7 @@ class FormService( val email = submission[form.emailFieldName] if (!email.isNullOrBlank()) return email } - return user?.email + return user?.deliveryEmail } @Transactional(readOnly = true) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/CmschUserDetailsService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/CmschUserDetailsService.kt index 6c4b84681..0d3fbbc7c 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/CmschUserDetailsService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/CmschUserDetailsService.kt @@ -22,7 +22,7 @@ class CmschUserDetails( override fun getPassword(): String? = userEntity.password - override fun getUsername(): String = userEntity.email + override fun getUsername(): String = userEntity.email ?: userEntity.internalId override fun isAccountNonExpired(): Boolean = true @@ -48,6 +48,7 @@ class CmschUserDetailsService( @Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED) override fun loadUserByUsername(username: String): UserDetails { val user = userRepository.findByEmailIgnoreCase(username) + .or { userRepository.findByInternalId(username) } .orElseThrow { UsernameNotFoundException("User not found with email: $username") } return CmschUserDetails(user, loginComponent) } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginComponent.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginComponent.kt index 423d5d7af..af3546c43 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginComponent.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginComponent.kt @@ -40,28 +40,17 @@ class LoginComponent( val authschGroup by SettingGroup(fieldName = "AuthSCH", description = "Csak akkor írd át ha tudod mit csinálsz! Ha elrontod nem fog beengedni, szóval óvatosan!") - var authschScopesRaw by StringSettingRef( - listOf(Scope.BASIC, Scope.SURNAME, Scope.GIVEN_NAME, Scope.EDU_PERSON_ENTILEMENT, Scope.MAIL).joinToString(","), + var authschScopes by StringSettingRef( + listOf(Scope.OPEN_ID, Scope.PROFILE, Scope.EMAIL, Scope.SAM_ACCOUNT_NAME, Scope.BME_UNIT_SCOPE, Scope.PEK_PROFILE).joinToString(","), serverSideOnly = true, fieldName = "Oauth scopeok", description = "Ezek lesznek elkérve a providertől; ezek vannak: " + Scope.entries.joinToString(", ") { it.name }) - val authschScopes = mutableListOf() - - override fun onInit() { - onPersist() - } - - override fun onPersist() { - authschScopes.clear() - val scopes = authschScopesRaw.replace(" ", "").split(",") + fun getAuthschScopes(): Set { + val scopes = authschScopes.split(",") .filter { it.isNotBlank() } - .mapNotNull { Scope.byNameOrNull(it) } + .mapNotNull { Scope.byNameOrNull(it.trim())?.scope } .distinct() - authschScopes.addAll(scopes) - authschScopesRaw = scopes.joinToString(",") { it.name } - log.info("Authsch scopes changed to '{}' and saved to the db as: '{}'", - authschScopes.map { it.name }, - authschScopesRaw) + return setOf(*scopes.toTypedArray(), Scope.OPEN_ID.scope) } var onlyBmeProvider by BooleanSettingRef(false, serverSideOnly = false, @@ -73,6 +62,10 @@ class LoginComponent( var authschPromotedFrontend by BooleanSettingRef(true, fieldName = "Authsch opció látszik (frontend)", description = "Ha ez be van kapcsolva, akkor a felhasználói felület /login oldalán látszik az AuthSCH SSO") + var authschAdminAddresses by StringSettingRef(serverSideOnly = true, fieldName = "ADMIN jogú emailcímek", + description = "Csak AuthSCH esetén! Ezeknek a felhasználóknak ADMIN joga lesz belépésnél. " + + "A sch-acc@sch.bme.hu emailcímek vesszővel felsorolva.") + /// ------------------------------------------------------------------------------------------------------------------- val ssoSecurityGroup by SettingGroup(fieldName = "SSO biztonság", diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginService.kt index c2d28db25..8ba50dc44 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginService.kt @@ -1,6 +1,6 @@ package hu.bme.sch.cmsch.component.login -import hu.bme.sch.cmsch.component.login.authsch.ProfileResponse +import hu.bme.sch.cmsch.component.login.authsch.AuthschProfile import hu.bme.sch.cmsch.component.login.google.GoogleUserInfoResponse import hu.bme.sch.cmsch.component.login.keycloak.KeycloakUserInfoResponse import hu.bme.sch.cmsch.component.team.TeamService @@ -43,7 +43,7 @@ class LoginService( private val log = LoggerFactory.getLogger(javaClass) private val userLocks = InternalIdLocks() - fun fetchUserEntity(profile: ProfileResponse): UserEntity { + fun fetchUserEntity(profile: AuthschProfile): UserEntity { val lock = userLocks.lockForKey(profile.internalId) try { var user: UserEntity @@ -64,10 +64,11 @@ class LoginService( profile.internalId, profile.neptun ?: "N/A", "", - (profile.surname ?: "") + " " + (profile.givenName ?: ""), + profile.fullName, "", - profile.email ?: "", - RoleType.BASIC, + profile.email, + role = RoleType.BASIC, + secondaryEmail = profile.secondaryEmail, groupName = "", group = null, guild = GuildType.UNKNOWN, major = MajorType.UNKNOWN, provider = AUTHSCH @@ -115,8 +116,8 @@ class LoginService( "", "${profile.familyName} ${profile.givenName}".take(254), "", - profile.email.take(254), - RoleType.BASIC, + profile.email.take(254).ifBlank { null }, + role = RoleType.BASIC, groupName = "", group = null, guild = GuildType.UNKNOWN, major = MajorType.UNKNOWN, provider = GOOGLE, @@ -164,7 +165,7 @@ class LoginService( user.groupName = user.group?.name ?: "" } - private fun updateFieldsForAuthsch(user: UserEntity, profile: ProfileResponse) { + private fun updateFieldsForAuthsch(user: UserEntity, profile: AuthschProfile) { // Generate CMSch id if not present if (user.cmschId.isBlank()) { profileService.generateProfileIdForUser(user) @@ -174,10 +175,22 @@ class LoginService( if (profile.neptun != null) { user.neptun = profile.neptun ?: user.neptun } - if (profile.email != null && profile.email?.isNotBlank() == true) { + if (profile.email != null && profile.email.isNotBlank() == true) { user.email = profile.email ?: user.email } + if (!profile.secondaryEmail.isNullOrBlank()) { + user.secondaryEmail = profile.secondaryEmail + } + + // Grant admin by BME directory address + val authschAdminAddresses = loginComponent.authschAdminAddresses.split(Regex(", *")).filter { it.isNotBlank() } + if (profile.email != null && authschAdminAddresses.any { it.equals(profile.email, ignoreCase = true) }) { + log.info("Granting ADMIN for ${user.fullName}") + user.role = RoleType.ADMIN + user.detailsImported = true + } + grantGuildAndGroup(user) grantRole(user) @@ -206,7 +219,7 @@ class LoginService( } // Assign groups and roles - if (profile.eduPersonEntitlement != null && user.role.value < RoleType.ADMIN.value) { + if (profile.activeMemberships.isNotEmpty() && user.role.value < RoleType.ADMIN.value) { assignStaffRole(profile, user) assignAdminRole(profile, user) assignOrganizerGroup(profile, user) @@ -219,8 +232,8 @@ class LoginService( } // Assign using unit-scope - val bmeUnitScopes = profile.bmeunitscope - if (unitScopeComponent.unitScopeGrantsEnabled && bmeUnitScopes != null) { + val bmeUnitScopes = profile.unitScopes + if (unitScopeComponent.unitScopeGrantsEnabled && !bmeUnitScopes.isNullOrEmpty()) { if (bmeUnitScopes.any { it.bme }) { processUnitScopeStatus(user, unitScopeComponent.bmeGrantRoleAttendee, @@ -264,6 +277,7 @@ class LoginService( unitScopeComponent.vbkNewbieGrantGroupName) } } + // Assigned only when the claim is actually present if (bmeUnitScopes != null) { user.unitScopes = bmeUnitScopes.joinToString(", ") } @@ -293,8 +307,8 @@ class LoginService( } } - if (user.email.isNotBlank() && !alreadySetGroupAndGuild) { - groupToUserMapping.findByEmailIgnoreCase(user.email).ifPresent { + if (!user.email.isNullOrBlank() && !alreadySetGroupAndGuild) { + groupToUserMapping.findByEmailIgnoreCase(user.email!!).ifPresent { user.major = it.major addUserToGroup(user, it) user.detailsImported = true @@ -311,8 +325,8 @@ class LoginService( applied = true } } - if (!applied && user.email.isNotBlank()) { - roleToUserMapping.findByEmailIgnoreCase(user.email).ifPresent { + if (!applied && !user.email.isNullOrBlank()) { + roleToUserMapping.findByEmailIgnoreCase(user.email!!).ifPresent { user.role = it.role } } @@ -347,7 +361,7 @@ class LoginService( } private fun assignStaffRole( - profile: ProfileResponse, + profile: AuthschProfile, user: UserEntity ) { val staffGroups = loginComponent.staffGroups @@ -357,10 +371,7 @@ class LoginService( if (staffGroups.isEmpty()) return - val grantStaffRole = profile.eduPersonEntitlement - ?.filter { it.end == null } - ?.any { staffGroups.contains(it.id) } - ?: false + val grantStaffRole = profile.activeMemberships.any { staffGroups.contains(it.id) } if (grantStaffRole) { log.info("Granting STAFF for ${user.fullName}") @@ -376,7 +387,7 @@ class LoginService( } private fun assignAdminRole( - profile: ProfileResponse, + profile: AuthschProfile, user: UserEntity ) { val adminGroups = loginComponent.adminGroups @@ -386,10 +397,7 @@ class LoginService( if (adminGroups.isEmpty()) return - val grantAdminRole = profile.eduPersonEntitlement - ?.filter { it.end == null } - ?.any { adminGroups.contains(it.id) } - ?: false + val grantAdminRole = profile.activeMemberships.any { adminGroups.contains(it.id) } if (grantAdminRole) { log.info("Granting ADMIN for ${user.fullName}") @@ -398,7 +406,7 @@ class LoginService( } private fun assignOrganizerGroup( - profile: ProfileResponse, + profile: AuthschProfile, user: UserEntity ) { val organizerGroups = loginComponent.organizerGroups @@ -408,10 +416,7 @@ class LoginService( if (organizerGroups.isEmpty()) return - val memberOfAnyOrganizerGroups = profile.eduPersonEntitlement - ?.filter { it.end == null } - ?.any { organizerGroups.contains(it.id) } - ?: false + val memberOfAnyOrganizerGroups = profile.activeMemberships.any { organizerGroups.contains(it.id) } if (memberOfAnyOrganizerGroups && user.role.value < RoleType.STAFF.value) { groups.findByName(loginComponent.organizerGroupName).ifPresent { @@ -453,8 +458,8 @@ class LoginService( "", "${profile.familyName} ${profile.givenName}", profile.preferredUsername, - profile.email, - RoleType.BASIC, + profile.email.ifBlank { null }, + role = RoleType.BASIC, groupName = "", group = null, guild = GuildType.UNKNOWN, major = MajorType.UNKNOWN, provider = KEYCLOAK, diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/PasswordLoginService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/PasswordLoginService.kt index 903a831fe..cbd7781dc 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/PasswordLoginService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/PasswordLoginService.kt @@ -298,6 +298,11 @@ class PasswordLoginService( log.error("EmailService is not available, cannot send password reset email to {}", user.email) return } + val recipient = user.deliveryEmail + if (recipient.isNullOrBlank()) { + log.error("User {} has no email address, cannot send password reset email", user.internalId) + return + } val templateName = loginComponent.passwordResetTemplate val template = service.getTemplateBySelector(templateName) if (template == null && templateName.isNotBlank()) { @@ -307,7 +312,7 @@ class PasswordLoginService( "name" to user.fullName, "link" to "${appComponent.siteUrl}reset-password?token=$token" ) - service.sendTemplatedEmail(null, template ?: getDefaultPasswordResetTemplate(), values, listOf(user.email)) + service.sendTemplatedEmail(null, template ?: getDefaultPasswordResetTemplate(), values, listOf(recipient)) } private fun getDefaultPasswordResetTemplate() = EmailTemplateEntity( diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/ActiveMembership.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/ActiveMembership.kt new file mode 100644 index 000000000..da00def50 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/ActiveMembership.kt @@ -0,0 +1,7 @@ +package hu.bme.sch.cmsch.component.login.authsch + +data class ActiveMembership( + val id: Long = 0, + val name: String = "", + val title: List = listOf() +) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/AuthschProfile.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/AuthschProfile.kt new file mode 100644 index 000000000..b5b181cbd --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/AuthschProfile.kt @@ -0,0 +1,108 @@ +package hu.bme.sch.cmsch.component.login.authsch + +import org.slf4j.LoggerFactory +import org.springframework.security.oauth2.core.oidc.user.OidcUser + +class AuthschProfile private constructor( + val internalId: String, + val email: String?, + val secondaryEmail: String?, + val fullName: String, + val neptun: String?, + val activeMemberships: List, + val unitScopes: List?, +) { + + companion object { + private val log = LoggerFactory.getLogger(AuthschProfile::class.java) + + const val SAM_ACCOUNT_NAME = "directory.sch.bme.hu:sAMAccountName" + const val EMAIL = "email" + const val NEPTUN = "bme.hu:niifPersonOrgID" + const val UNIT_SCOPE = "meta.bme.hu:unitScope/v1" + const val ACTIVE_MEMBERSHIPS = "pek.sch.bme.hu:activeMemberships/v1" + + fun from(oidcUser: OidcUser): AuthschProfile { + val internalId = oidcUser.subject + ?: throw IllegalStateException("The AuthSCH userinfo response contains no 'sub' claim") + + val fullName = listOfNotNull( + oidcUser.getClaim("family_name") as? String, + oidcUser.getClaim("given_name") as? String + ).joinToString(" ").ifBlank { oidcUser.getClaim("name") as? String ?: "" } + + val (email, secondaryEmail) = readEmails(oidcUser) + + return AuthschProfile( + internalId = internalId, + email = email, + secondaryEmail = secondaryEmail, + fullName = fullName, + neptun = oidcUser.getClaim(NEPTUN) as? String, + activeMemberships = parseActiveMemberships(oidcUser), + unitScopes = parseUnitScopes(oidcUser) + ) + } + + + private fun readEmails(oidcUser: OidcUser): Pair { + val samAccountName = (oidcUser.getClaim(SAM_ACCOUNT_NAME) as? String)?.takeIf { it.isNotBlank() } + val fallback = (oidcUser.getClaim(EMAIL) as? String)?.takeIf { it.isNotBlank() } + + if (samAccountName != null) + return "$samAccountName@sch.bme.hu" to fallback + + if (fallback == null) { + log.warn("Neither the {} nor the {} claim is present for user {}, so the user is saved without an email", SAM_ACCOUNT_NAME, EMAIL, oidcUser.subject) + return null to null + } + log.info("The {} claim is missing for user {}, saving the {} claim as the secondary email address", SAM_ACCOUNT_NAME, oidcUser.subject, EMAIL) + return null to fallback + } + + private fun parseActiveMemberships(oidcUser: OidcUser): List { + val raw = oidcUser.getClaim(ACTIVE_MEMBERSHIPS) + if (raw == null) { + log.warn("The {} claim is missing, so no staff/admin/organizer membership can be read", ACTIVE_MEMBERSHIPS) + return listOf() + } + val memberships = (raw as? List<*>)?.filterIsInstance>() ?: run { + log.warn("The {} claim is a {} instead of a list, ignoring it", ACTIVE_MEMBERSHIPS, raw::class.simpleName) + return listOf() + } + val parsed = memberships.mapNotNull { entry -> + runCatching { + ActiveMembership( + id = (entry["id"] as? Number)?.toLong() ?: entry["id"]?.toString()?.toLongOrNull() ?: 0L, + name = entry["name"]?.toString() ?: "", + title = (entry["title"] as? List<*>).orEmpty().map { title -> title.toString() } + ) + }.onFailure { error -> + log.warn("Could not parse the activeMemberships entry {}: {}", entry, error.toString()) + }.getOrNull() + } + if (parsed.size != memberships.size) + log.warn("Skipped {} malformed activeMemberships entries", memberships.size - parsed.size) + return parsed + } + + private fun parseUnitScopes(oidcUser: OidcUser): List? { + val raw = oidcUser.getClaim(UNIT_SCOPE) ?: return null + val names = when (raw) { + is String -> raw.split(Regex("[\\s,]+")).filter { it.isNotBlank() } + is Collection<*> -> raw.mapNotNull { it?.toString() } + else -> { + log.warn("Unexpected type {} for the {} claim, ignoring it", raw::class.simpleName, UNIT_SCOPE) + + // Don't erase the old claim from DB if we can't parse the new one + return null + } + } + return names.mapNotNull { name -> + BMEUnitScope.byNameOrNull(name).also { + if (it == null) log.warn("Unknown BME unit scope '{}', ignoring it", name) + } + } + } + } +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/BMEUnitScope.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/BMEUnitScope.kt index 9d79129cf..182bd781c 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/BMEUnitScope.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/BMEUnitScope.kt @@ -15,5 +15,9 @@ enum class BMEUnitScope( BME_VIK_NEWBIE(true, true, false,true, true), BME_VBK(true, false, true,false, false), BME_VBK_ACTIVE(true, false, true,true, false), - BME_VBK_NEWBIE(true, false, true,true, true) + BME_VBK_NEWBIE(true, false, true,true, true); + + companion object { + fun byNameOrNull(name: String): BMEUnitScope? = entries.find { it.name == name } + } } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/CmschAuthschUser.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/CmschAuthschUser.kt index c8fd0677d..67e57de0a 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/CmschAuthschUser.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/CmschAuthschUser.kt @@ -3,23 +3,31 @@ package hu.bme.sch.cmsch.component.login.authsch import hu.bme.sch.cmsch.component.login.CmschUser import hu.bme.sch.cmsch.model.RoleType import org.springframework.security.core.GrantedAuthority -import org.springframework.security.oauth2.core.user.DefaultOAuth2User +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.core.oidc.user.OidcUser import java.io.Serializable import java.security.Principal class CmschAuthschUser( + private val oidcUser: OidcUser, override val id: Int, override val internalId: String, override var role: RoleType, override var permissionsAsList: List, override val userName: String, - authorities: List, override val groupId: Int?, - override val groupName: String -) : DefaultOAuth2User(authorities, mapOf("internal_id" to internalId), "internal_id"), CmschUser, Principal, Serializable { + override val groupName: String, +) : OidcUser by oidcUser, CmschUser, Principal, Serializable { + + companion object { + private const val serialVersionUID: Long = 1L + } override fun getName() = internalId + override fun getAuthorities(): Collection = + listOf(SimpleGrantedAuthority("ROLE_${role.name}")) + override fun hasPermission(permission: String): Boolean { return permissionsAsList.contains(permission) } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/PersonEntitlement.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/PersonEntitlement.kt deleted file mode 100644 index 044667d2f..000000000 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/PersonEntitlement.kt +++ /dev/null @@ -1,10 +0,0 @@ -package hu.bme.sch.cmsch.component.login.authsch - -data class PersonEntitlement( - var id: Long = 0, - var name: String = "", - var status: String = "", - var title: List = listOf(), - var start: String = "", - var end: String? = null -) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/ProfileResponse.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/ProfileResponse.kt deleted file mode 100644 index f0d5bbeea..000000000 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/ProfileResponse.kt +++ /dev/null @@ -1,40 +0,0 @@ -package hu.bme.sch.cmsch.component.login.authsch - -import com.fasterxml.jackson.annotation.JsonProperty - -data class ProfileResponse( - @set:JsonProperty("internal_id") - var internalId: String = "", - - var displayName: String? = null, - - @set:JsonProperty("sn") - var surname: String? = null, - - var givenName: String? = null, - - @set:JsonProperty("mail") - var email: String? = null, - - @set:JsonProperty("niifPersonOrgID") - var neptun: String? = null, - - var linkedAccounts: Map? = mapOf(), - - var lastSync: Map? = mapOf(), - - var eduPersonEntitlement: List? = listOf(), - - var roomNumber: String? = null, - - var mobile: String? = null, - - @set:JsonProperty("niifEduPersonAttendedCourse") - var courses: List? = null, - - var entrants: List? = listOf(), - - var admembership: List? = listOf(), - - var bmeunitscope: List? = listOf(), -) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/Scope.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/Scope.kt index fd871ce7f..93f3b89bc 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/Scope.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/authsch/Scope.kt @@ -1,24 +1,14 @@ package hu.bme.sch.cmsch.component.login.authsch -/** - * Access Scope based on https://git.sch.bme.hu/kszk/authsch/wikis/api - */ + enum class Scope(val scope: String) { - BASIC("basic"), - DISPLAY_NAME("displayName"), - SURNAME("sn"), - GIVEN_NAME("givenName"), - MAIL("mail"), - NEPTUN_CODE("niifPersonOrgID"), - LINKED_ACCOUNTS("linkedAccounts"), - EDU_PERSON_ENTILEMENT("eduPersonEntitlement"), - ROOM_NUMBER("roomNumber"), - MOBILE("mobile"), - COURSES("niifEduPersonAttendedCourse"), - ENTRANTS("entrants"), - ACTIVE_DIRECTORY_MEMBERSHIP("admembership"), - BME_UNIT_SCOPE("bmeunitscope"), - PERMANENT_ADDRESS("permanentaddress"); + OPEN_ID("openid"), + PROFILE("profile"), + EMAIL("email"), + SAM_ACCOUNT_NAME("directory.sch.bme.hu:sAMAccountName"), + NEPTUN_CODE("bme.hu:niifPersonOrgID"), + BME_UNIT_SCOPE("meta.bme.hu:unitScope"), + PEK_PROFILE("pek.sch.bme.hu:profile"); companion object { fun byNameOrNull(name: String): Scope? { diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/profile/ProfileService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/profile/ProfileService.kt index e310e9c81..fdcfce01e 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/profile/ProfileService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/profile/ProfileService.kt @@ -76,7 +76,7 @@ class ProfileService( alias = profileComponent.showAlias.mapIfTrue { user.alias }, guild = profileComponent.showGuild.mapIfTrue { user.guild }, - email = profileComponent.showEmail.mapIfTrue { user.email }, + email = profileComponent.showEmail.mapIfTrue { user.deliveryEmail }, neptun = profileComponent.showNeptun.mapIfTrue { user.neptun }, cmschId = mapQr(user), major = profileComponent.showMajor.mapIfTrue { user.major }, diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/FreestyleRaceRecordController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/FreestyleRaceRecordController.kt index 89dcf7ed6..50a47fe9c 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/FreestyleRaceRecordController.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/FreestyleRaceRecordController.kt @@ -146,7 +146,7 @@ class FreestyleRaceRecordController( } private fun mapUsername(it: UserEntity) = - "${it.fullNameWithAlias} | ${it.id} | [${it.provider.firstOrNull() ?: 'n'}] ${it.email}" + "${it.fullNameWithAlias} | ${it.id} | [${it.provider.firstOrNull() ?: 'n'}] ${it.email ?: ""}" private fun mapUsername(it: UserSelectorView) = "${it.fullNameWithAlias} | ${it.id} | [${it.provider.firstOrNull() ?: 'n'}] ${it.email}" diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/RaceRecordController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/RaceRecordController.kt index 5878da0b5..e79ff94d0 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/RaceRecordController.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/RaceRecordController.kt @@ -156,7 +156,7 @@ class RaceRecordController( private fun mapUsername(it: UserEntity) = - "${it.fullNameWithAlias} | ${it.id} | [${it.provider.firstOrNull() ?: 'n'}] ${it.email}" + "${it.fullNameWithAlias} | ${it.id} | [${it.provider.firstOrNull() ?: 'n'}] ${it.email ?: ""}" private fun mapUsername(it: UserSelectorView) = "${it.fullNameWithAlias} | ${it.id} | [${it.provider.firstOrNull() ?: 'n'}] ${it.email}" diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/RaceService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/RaceService.kt index 9c275df0c..2c12ebed6 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/RaceService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/race/RaceService.kt @@ -216,7 +216,7 @@ open class RaceService( .groupBy { it.userId } .map { submission -> val email = if (fetchEmail) userRepository.findById(submission.key ?: 0) - .map { it.email } + .map { it.email ?: "n/a" } .orElse("n/a") else "" RaceEntryDto( submission.key ?: 0, @@ -234,7 +234,7 @@ open class RaceService( .groupBy { it.userId } .map { submission -> val email = if (fetchEmail) userRepository.findById(submission.key ?: 0) - .map { it.email } + .map { it.email ?: "n/a" } .orElse("n/a") else "" RaceEntryDto( submission.key ?: 0, diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/support/SupportApiController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/support/SupportApiController.kt index 4ee0f304e..7a1de4a4f 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/support/SupportApiController.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/support/SupportApiController.kt @@ -220,6 +220,6 @@ class SupportApiController( } private fun getEmail(user: CmschUser): String { - return user.asUserEntity(userRepository).email + return user.asUserEntity(userRepository).deliveryEmail ?: "" } } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/support/SupportService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/support/SupportService.kt index a5a1985b8..538aa8987 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/support/SupportService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/support/SupportService.kt @@ -441,7 +441,7 @@ class SupportService( } val supportUser = userRepository.findByInternalId(userId).orElse(null) - val supportUserEmail = supportUser?.email ?: run { + val supportUserEmail = supportUser?.deliveryEmail ?: run { log.warn("Could not find email for support user '{}', skipping assignment notification", userId) return } @@ -476,7 +476,7 @@ class SupportService( if (selector.isBlank() || thread.solverInternalId.isBlank()) return val solverUser = userRepository.findByInternalId(thread.solverInternalId).orElse(null) - val solverEmail = solverUser?.email ?: run { + val solverEmail = solverUser?.deliveryEmail ?: run { log.warn("Could not find email for thread solver '{}', skipping reply notification", thread.solverInternalId) return } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/AppConfig.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/AppConfig.kt index 4416d4a8f..b9b463f6b 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/AppConfig.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/AppConfig.kt @@ -45,7 +45,8 @@ class AppConfig { return source } try { - return URI("https://$source").toURL() + val issuer = source.toString() + return URI(if (issuer.contains("://")) issuer else "https://$issuer").toURL() } catch (_: Exception) { // Ignore } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/AuthConfig.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/AuthConfig.kt index 0a9f2264c..a839f923b 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/AuthConfig.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/AuthConfig.kt @@ -1,11 +1,13 @@ package hu.bme.sch.cmsch.config +import hu.bme.sch.cmsch.component.login.authsch.Scope import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.oauth2.client.registration.ClientRegistration import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.ClientRegistrations import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository import org.springframework.security.oauth2.core.AuthenticationMethod import org.springframework.security.oauth2.core.AuthorizationGrantType @@ -16,8 +18,7 @@ class AuthConfig( @param:Value("\${spring.security.oauth2.client.registration.authsch.client-id:}") private val authschId: String, @param:Value("\${spring.security.oauth2.client.registration.authsch.client-secret:}") private val authschSecret: String, @param:Value("\${spring.security.oauth2.client.registration.authsch.redirect-uri:}") private val authschRedirectUrl: String, - @param:Value("\${spring.security.oauth2.client.provider.authsch.authorization-uri:}") private val authschAuthorizationUrl: String, - @param:Value("\${spring.security.oauth2.client.provider.authsch.token-uri:}") private val authschTokenUri: String, + @param:Value("\${authsch.issuer:https://auth.sch.bme.hu}") private val authschIssuer: String, @param:Value("\${spring.security.oauth2.client.registration.google.client-id:}") private val googleId: String, @param:Value("\${spring.security.oauth2.client.registration.google.client-secret:}") private val googleSecret: String, @@ -34,8 +35,6 @@ class AuthConfig( @param:Value("\${spring.security.oauth2.client.provider.keycloak.jwk-set-uri:http://localhost:8081/auth/realms/master/protocol/openid-connect/certs}") private val keycloakJwkSet: String, @param:Value("\${spring.security.oauth2.client.provider.keycloak.user-name-attribute:}") private val keycloakUserAttributeName: String, @param:Value("\${custom.keycloak.issuer:http://localhost:8081/auth/realms/master}") private val keycloakIssuer: String, - - @param:Value("\${authsch.config.user-info-uri:https://auth.sch.bme.hu/api/profile}") private val userInfoUri: String, ) { private val log = LoggerFactory.getLogger(javaClass) @@ -43,16 +42,16 @@ class AuthConfig( @Bean fun clientRegistrationRepository(): ClientRegistrationRepository { val authProviders = mutableListOf() - log.info("Using oauth2 sso: authsch") + log.info("Using openid sso: authsch") authProviders.add(authschClientRegistration()) if (googleId.isNotBlank() && googleId != "no") { - log.info("Using oauth2 sso: google") + log.info("Using openid sso: google") authProviders.add(googleClientRegistration()) } if (keycloakId.isNotBlank() && keycloakId != "no") { - log.info("Using oauth2 sso: keycloak") + log.info("Using openid sso: keycloak") authProviders.add(keycloakClientRegistration()) } @@ -60,20 +59,35 @@ class AuthConfig( } private fun authschClientRegistration(): ClientRegistration { - return ClientRegistration.withRegistrationId("authsch") + val builder = authschDiscoveryOrDefaults() + .registrationId("authsch") .clientId(authschId) .clientSecret(authschSecret) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri(authschRedirectUrl) - .scope("basic", "displayName", "sn", "givenName", "mail") - .authorizationUri(authschAuthorizationUrl) - .tokenUri(authschTokenUri) - .userInfoUri(userInfoUri) - .userInfoAuthenticationMethod(AuthenticationMethod.QUERY) - .userNameAttributeName("internal_id") + .scope(*Scope.entries.map { it.scope }.toTypedArray()) + .userNameAttributeName("sub") + .userInfoAuthenticationMethod(AuthenticationMethod.HEADER) .clientName("AuthSch") - .build() + return builder.build() + } + + + private fun authschDiscoveryOrDefaults(): ClientRegistration.Builder { + return try { + ClientRegistrations.fromIssuerLocation(authschIssuer) + } catch (e: Exception) { + log.error("Could not read the OIDC discovery document of $authschIssuer, " + + "falling back to the well known AuthSCH endpoints. Logins will not work until " + + "AuthSCH is reachable again.", e) + ClientRegistration.withRegistrationId("authsch") + .issuerUri(authschIssuer) + .authorizationUri("$authschIssuer/site/login") + .tokenUri("$authschIssuer/oauth2/token") + .userInfoUri("$authschIssuer/oidc/userinfo") + .jwkSetUri("$authschIssuer/oidc/jwks") + } } private fun googleClientRegistration(): ClientRegistration { diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/CustomAuthorizationRequestResolver.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/CustomAuthorizationRequestResolver.kt index 459a0c5e1..e615919de 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/CustomAuthorizationRequestResolver.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/CustomAuthorizationRequestResolver.kt @@ -1,6 +1,7 @@ package hu.bme.sch.cmsch.config import hu.bme.sch.cmsch.component.login.LoginComponent +import hu.bme.sch.cmsch.component.login.authsch.Scope import jakarta.servlet.http.HttpServletRequest import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver @@ -54,24 +55,25 @@ class CustomAuthorizationRequestResolver( } private fun customizeAuthorizationRequest(request: OAuth2AuthorizationRequest, clientRegistrationId: String): OAuth2AuthorizationRequest? { + val authschScopes = loginComponent.getAuthschScopes() return when(clientRegistrationId) { AUTHSCH -> { if (loginComponent.onlyBmeProvider) { val target = OAuth2AuthorizationRequest .from(request) - .scopes(loginComponent.authschScopes.map { it.scope }.toSet()) + .scopes(authschScopes) .build() OAuth2AuthorizationRequest.from(request) .authorizationUri("https://auth.sch.bme.hu/Shibboleth.sso/Login") .parameters { it["target"] = target.authorizationRequestUri } - .scopes(loginComponent.authschScopes.map { it.scope }.toSet()) + .scopes(authschScopes) .build() } else { OAuth2AuthorizationRequest .from(request) - .scopes(loginComponent.authschScopes.map { it.scope }.toSet()) + .scopes(authschScopes) .build() } } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt index 00464b67a..a9b17aee4 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt @@ -6,8 +6,8 @@ import hu.bme.sch.cmsch.component.login.LoginComponent import hu.bme.sch.cmsch.component.login.LoginRejectedException import hu.bme.sch.cmsch.component.login.LoginService import hu.bme.sch.cmsch.component.login.SessionFilterConfigurer +import hu.bme.sch.cmsch.component.login.authsch.AuthschProfile import hu.bme.sch.cmsch.component.login.authsch.CmschAuthschUser -import hu.bme.sch.cmsch.component.login.authsch.ProfileResponse import hu.bme.sch.cmsch.component.login.google.CmschGoogleUser import hu.bme.sch.cmsch.component.login.google.GoogleUserInfoResponse import hu.bme.sch.cmsch.component.login.keycloak.KeycloakUserInfoResponse @@ -34,10 +34,10 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.DelegatingPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser -import org.springframework.security.oauth2.core.user.DefaultOAuth2User +import org.springframework.security.oauth2.core.oidc.user.OidcUser import org.springframework.security.web.SecurityFilterChain import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.bodyToMono @@ -84,18 +84,14 @@ class SecurityConfig( return config.authenticationManager } - var authschUserServiceClient = WebClient.builder() - .baseUrl("https://auth.sch.bme.hu/api") - .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .defaultHeader(HttpHeaders.USER_AGENT, "AuthSchKotlinAPI") - .build() - var googleUserServiceClient = WebClient.builder() .baseUrl("https://www.googleapis.com/oauth2/v3") .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.USER_AGENT, "AuthSchKotlinAPI") .build() + private val oidcUserService = OidcUserService() + @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { http.authorizeHttpRequests { @@ -178,14 +174,17 @@ class SecurityConfig( ) }.userInfoEndpoint { userInfo -> userInfo - .oidcUserService { - if (it.clientRegistration.clientId.contains("google")) { - resolveGoogleUser(it) - } else { - resolveKeycloakUser(it) + .oidcUserService { request -> + val user: OidcUser = when (request.clientRegistration.registrationId) { + AUTHSCH -> resolveAuthschUser(request) + GOOGLE -> resolveGoogleUser(request) + KEYCLOAK -> resolveKeycloakUser(request) + else -> throw IllegalStateException( + "No OIDC user handler is registered for '${request.clientRegistration.registrationId}'" + ) } + user } - .userService { resolveAuthschUser(it) } }.defaultSuccessUrl("/control/post-login") .failureHandler { request, response, exception -> val message = if (exception is LoginRejectedException) @@ -213,31 +212,20 @@ class SecurityConfig( return http.build() } - private fun resolveAuthschUser(request: OAuth2UserRequest): DefaultOAuth2User { - // The API returns `test/json` which is an invalid mime type - val authschProfileJson: String? = authschUserServiceClient.get() - .uri { uriBuilder -> - uriBuilder.path("/profile/") - .queryParam("access_token", request.accessToken.tokenValue) - .build() - } - .retrieve() - .bodyToMono() - .block() - - val profile = objectMapper.readerFor(ProfileResponse::class.java) - .readValue(authschProfileJson)!! + private fun resolveAuthschUser(request: OidcUserRequest): CmschAuthschUser { + val oidcUser = oidcUserService.loadUser(request) + val profile = AuthschProfile.from(oidcUser) val userEntity = authschLoginService.fetchUserEntity(profile) auditLogService.login(userEntity, "authsch user login g:${userEntity.group} r:${userEntity.role}") return CmschAuthschUser( + oidcUser = oidcUser, id = userEntity.id, internalId = userEntity.internalId, role = userEntity.role, permissionsAsList = userEntity.permissionsAsList, userName = userEntity.fullName, - authorities = mutableListOf(SimpleGrantedAuthority("ROLE_${userEntity.role.name}")), groupId = userEntity.groupId, groupName = userEntity.groupName ) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/userhandling/PermissionGroupAssignPage.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/userhandling/PermissionGroupAssignPage.kt index 229222d78..a177bab33 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/userhandling/PermissionGroupAssignPage.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/userhandling/PermissionGroupAssignPage.kt @@ -138,7 +138,7 @@ class PermissionGroupAssignPage( name = it.fullName, group = it.groupName, neptun = it.neptun, - email = it.email, + email = it.email ?: "", ) } } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/userhandling/UserController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/userhandling/UserController.kt index 0957b22f7..4fbf32da2 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/userhandling/UserController.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/userhandling/UserController.kt @@ -112,6 +112,9 @@ class UserController( override fun onEntityPreSave(entity: UserEntity, auth: Authentication): Boolean { profileService.generateProfileIdForUser(entity) + if (entity.email.isNullOrBlank()) entity.email = null + if (entity.secondaryEmail.isNullOrBlank()) entity.secondaryEmail = null + if (entity.groupName.isNotBlank()) { transactionManager.transaction(readOnly = true) { groups.findByName(entity.groupName) }.ifPresentOrElse({ entity.group = it @@ -154,6 +157,7 @@ class UserController( neptun = it.neptun, groupName = it.groupName, email = it.email, + secondaryEmail = it.secondaryEmail, guild = it.guild ) } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/model/UserEntity.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/UserEntity.kt index 861144e64..8519e61af 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/model/UserEntity.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/UserEntity.kt @@ -77,6 +77,7 @@ enum class MajorType { Index(name = "idx_userentity_neptun", columnList = "neptun"), Index(name = "idx_userentity_groupname", columnList = "groupName"), Index(name = "idx_userentity_email", columnList = "email", unique = true), + Index(name = "idx_userentity_secondaryemail", columnList = "secondaryEmail"), Index(name = "idx_userentity_confirmationtoken", columnList = "confirmationToken"), Index(name = "idx_userentity_passwordresettoken", columnList = "passwordResetToken"), Index(name = "idx_userentity_group", columnList = "group_id") @@ -129,16 +130,24 @@ data class UserEntity( var alias: String = "", @field:JsonView(value = [ Edit::class ]) - @Column(nullable = false) + @Column(nullable = true) @property:GenerateInput(order = 6, label = "Email cím") @property:GenerateOverview(columnName = "Email", order = 4, useForSearch = true) @property:ImportFormat - var email: String = "", + var email: String? = null, + + @field:JsonView(value = [ Edit::class ]) + @Column(nullable = true) + @property:GenerateInput(order = 7, label = "Másodlagos email", + note = "Authsch-ből jövő email, ha nincs sAMAccountName. Innen megy a kiküldés, ha az elsődleges üres.") + @property:GenerateOverview(columnName = "Másodlagos email", order = 5, useForSearch = true) + @property:ImportFormat + var secondaryEmail: String? = null, @field:JsonView(value = [ Edit::class, FullDetails::class ]) @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.VARCHAR) - @property:GenerateInput(type = InputType.BLOCK_SELECT, order = 7, label = "Jogkör", + @property:GenerateInput(type = InputType.BLOCK_SELECT, order = 8, label = "Jogkör", source = [ "GUEST", "BASIC", "ATTENDEE", "PRIVILEGED", "STAFF", "ADMIN", "SUPERUSER" ], minimumRole = RoleType.ADMIN, note = "BASIC = belépett, STAFF = rendező, ADMIN = minden jog") @property:GenerateOverview(visible = false) @@ -147,7 +156,7 @@ data class UserEntity( @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) @Column(nullable = false) - @property:GenerateInput(type = InputType.ENTITY_SELECT, order = 8, label = "Csoport", entitySource = "GroupEntity", minimumRole = RoleType.STAFF) + @property:GenerateInput(type = InputType.ENTITY_SELECT, order = 9, label = "Csoport", entitySource = "GroupEntity", minimumRole = RoleType.STAFF) @property:GenerateOverview(columnName = "Csoport", centered = true, order = 3, useForSearch = true) @property:ImportFormat override var groupName: String = "", @@ -159,7 +168,7 @@ data class UserEntity( @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.VARCHAR) - @property:GenerateInput(type = InputType.BLOCK_SELECT, order = 9, label = "Gárda", source = [ "UNKNOWN", "BLACK", "BLUE", "RED", "WHITE", "YELLOW", "PURPLE" ]) + @property:GenerateInput(type = InputType.BLOCK_SELECT, order = 10, label = "Gárda", source = [ "UNKNOWN", "BLACK", "BLUE", "RED", "WHITE", "YELLOW", "PURPLE" ]) @property:GenerateOverview(visible = false) @property:ImportFormat var guild: GuildType = GuildType.UNKNOWN, @@ -167,27 +176,27 @@ data class UserEntity( @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.VARCHAR) - @property:GenerateInput(type = InputType.BLOCK_SELECT, order = 10, label = "Szak", source = [ "UNKNOWN", "IT", "EE", "BIS", "BPROF" ]) + @property:GenerateInput(type = InputType.BLOCK_SELECT, order = 11, label = "Szak", source = [ "UNKNOWN", "IT", "EE", "BIS", "BPROF" ]) @property:GenerateOverview(visible = false) @property:ImportFormat var major: MajorType = MajorType.UNKNOWN, @field:JsonView(value = [ Edit::class ]) @Column(nullable = false) - @property:GenerateInput(order = 11, label = "Forrás", note = "Honnan jön az adat (authsch, google, keycloak)") + @property:GenerateInput(order = 12, label = "Forrás", note = "Honnan jön az adat (authsch, google, keycloak)") @property:GenerateOverview(visible = false) @property:ImportFormat var provider: String = "", @field:JsonView(value = [ Edit::class ]) @Column(nullable = false) - @property:GenerateInput(order = 12, label = "Profilkép", enabled = true) + @property:GenerateInput(order = 13, label = "Profilkép", enabled = true) @property:ImportFormat var profilePicture: String = "", @field:JsonView(value = [ Edit::class ]) @Column(nullable = false) - @property:GenerateInput(order = 13, label = "Jogviszonyok", note = "Melyik kar, milyen szak, aktív-e és gólya-e?") + @property:GenerateInput(order = 14, label = "Jogviszonyok", note = "Melyik kar, milyen szak, aktív-e és gólya-e?") @property:GenerateOverview(visible = false) @property:ImportFormat var unitScopes: String = "", @@ -294,6 +303,10 @@ data class UserEntity( val fullNameWithAlias: String get() = if (alias != "") "$fullName ($alias)" else fullName + @get:Transient + val deliveryEmail: String? + get() = email?.takeIf { it.isNotBlank() } ?: secondaryEmail?.takeIf { it.isNotBlank() } + override var permissionsAsList get() = permissions.split(",") + permissionGroupsResolved.split(",") set(value) = throw RuntimeException("Value cannot be changed") diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/repository/UserRepository.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/repository/UserRepository.kt index b434e6d1c..15bb1cdc6 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/repository/UserRepository.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/repository/UserRepository.kt @@ -27,7 +27,8 @@ data class UserHandlerView( val neptun: String, val guild: GuildType, val groupName: String, - val email: String + val email: String, + val secondaryEmail: String ) @Repository @@ -46,10 +47,10 @@ interface UserRepository : CrudRepository, fun findAllByRoleOrRoleOrPermissionsNot(role1: RoleType, role2: RoleType, emptyString: String): MutableList fun findAllByRoleOrRole(role1: RoleType, role2: RoleType): MutableList - @Query("SELECT NEW hu.bme.sch.cmsch.repository.UserSelectorView(e.id, e.fullName, e.alias, e.provider, e.email) FROM UserEntity e") + @Query("SELECT NEW hu.bme.sch.cmsch.repository.UserSelectorView(e.id, e.fullName, e.alias, e.provider, coalesce(e.email, e.secondaryEmail, '')) FROM UserEntity e") fun findAllSelectorView(): List - @Query("SELECT NEW hu.bme.sch.cmsch.repository.UserHandlerView(e.id, e.fullName, e.alias, e.neptun, e.guild, e.groupName, e.email) FROM UserEntity e") + @Query("SELECT NEW hu.bme.sch.cmsch.repository.UserHandlerView(e.id, e.fullName, e.alias, e.neptun, e.guild, e.groupName, coalesce(e.email, ''), coalesce(e.secondaryEmail, '')) FROM UserEntity e") fun findAllUserHandlerView(): List fun findAllByPermissionGroupsNot(emptyString: String): List diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/service/AdminMenuService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/service/AdminMenuService.kt index 0304d2377..8c94d9534 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/service/AdminMenuService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/service/AdminMenuService.kt @@ -152,10 +152,11 @@ class AdminMenuService( val userEntity = userService.getById(user.internalId) val config = userService.resolveConfig(userEntity.config) + val email = userEntity.deliveryEmail val result = UserSiteContext( userName = user.userName, - email = userEntity.email, - emailHash = if (userEntity.email.isEmpty()) userEntity.fullName.md5() else userEntity.email.md5(), + email = email ?: "", + emailHash = if (email.isNullOrBlank()) userEntity.fullName.md5() else email.md5(), profilePicture = userEntity.profilePicture, role = user.role, group = userEntity.groupName, diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/service/UserService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/service/UserService.kt index 5cc15bca5..58909c4fb 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/service/UserService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/service/UserService.kt @@ -31,6 +31,8 @@ open class UserService( @Retryable(value = [SQLException::class], maxRetries = 5, delay = 500L, multiplier = 1.5) @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) open fun save(user: UserEntity) { + if (user.email.isNullOrBlank()) user.email = null + if (user.secondaryEmail.isNullOrBlank()) user.secondaryEmail = null users.save(user) } diff --git a/backend/src/main/resources/config/application-env.properties b/backend/src/main/resources/config/application-env.properties index a8bc1be3b..fe40ea8ba 100644 --- a/backend/src/main/resources/config/application-env.properties +++ b/backend/src/main/resources/config/application-env.properties @@ -46,6 +46,7 @@ hu.bme.sch.cmsch.startup.mailgun-token=${MAILGUN_TOKEN} hu.bme.sch.cmsch.startup.sysadmins=${SYSADMINS:6af3c8b0-592c-6864-8dd9-5f354edfc0be} hu.bme.sch.cmsch.login.googleAdminAddresses=${GOOGLE_ADMIN_ADDRESSES:} +hu.bme.sch.cmsch.login.authschAdminAddresses=${AUTHSCH_ADMIN_ADDRESSES:} cmsch.frontend.production-url=${FRONTEND_URL} cmsch.backend.allowed-origin-patterns=${ALLOWED_ORIGIN_PATTERNS:} hu.bme.sch.cmsch.app.siteUrl=${FRONTEND_URL}/ diff --git a/backend/src/main/resources/config/application.properties b/backend/src/main/resources/config/application.properties index 3e36dddb6..7a74c2fa0 100644 --- a/backend/src/main/resources/config/application.properties +++ b/backend/src/main/resources/config/application.properties @@ -156,11 +156,7 @@ spring.security.oauth2.client.registration.authsch.client-id=7016457823559255914 spring.security.oauth2.client.registration.authsch.client-secret=oDER4WiC9c2TWQiEe8xFZCpOcW41fyuK3zbvMAyLcpez7C4b0qhZtMnLihCEWxOOnqAzqyAJee9bg34g spring.security.oauth2.client.registration.authsch.redirect-uri=http://localhost:8080/login/oauth2/code/authsch -spring.security.oauth2.client.registration.authsch.authorization-grant-type=authorization_code -spring.security.oauth2.client.provider.authsch.token-uri=https://auth.sch.bme.hu/oauth2/token -spring.security.oauth2.client.provider.authsch.authorization-uri=https://auth.sch.bme.hu/site/login -spring.security.oauth2.client.provider.authsch.user-name-attribute=internal_id -spring.security.oauth2.client.provider.authsch.user-info-authentication-method=header +authsch.issuer=https://auth.sch.bme.hu spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google spring.security.oauth2.client.registration.google.client-id=no spring.security.oauth2.client.registration.google.client-secret=no diff --git a/helm/cmsch/templates/cmsch-config.yml b/helm/cmsch/templates/cmsch-config.yml index 11309f0c6..137d3ba2b 100644 --- a/helm/cmsch/templates/cmsch-config.yml +++ b/helm/cmsch/templates/cmsch-config.yml @@ -12,6 +12,7 @@ data: BACKEND_URL: "{{ .Values.apiHostProtocol }}://{{ .Values.apiHost }}/" SYSADMINS: {{ .Values.superusers.pekIds | quote }} GOOGLE_ADMIN_ADDRESSES: {{ .Values.superusers.googleEmails | quote }} + AUTHSCH_ADMIN_ADDRESSES: {{ .Values.superusers.authschEmails | quote }} SESSION_DURATION_SECONDS: {{ .Values.sessionDurationSeconds | quote}} {{ if .Values.s3.enabled }} diff --git a/helm/cmsch/values.yaml b/helm/cmsch/values.yaml index ba9236aa4..cd5f8b984 100644 --- a/helm/cmsch/values.yaml +++ b/helm/cmsch/values.yaml @@ -79,6 +79,7 @@ swaggerEnabled: false superusers: pekIds: "" googleEmails: "" + authschEmails: "" resources: storageClassName: memory-ssd