feat: Remote Auth

This commit is contained in:
Oleh Astappiev
2025-04-21 13:27:15 +02:00
committed by Aditya Chandel
parent cb552b5ee2
commit 4c01561f1b
10 changed files with 150 additions and 27 deletions

View File

@@ -10,25 +10,22 @@ COPY ./booklore-ui /angular-app/
RUN npm run build --configuration=production
# Stage 2: Build the Spring Boot app with Gradle
FROM gradle:jdk21-alpine AS springboot-build
FROM gradle:8-jdk21-alpine AS springboot-build
WORKDIR /springboot-app
COPY ./booklore-api/gradlew ./booklore-api/gradle/ /springboot-app/
COPY ./booklore-api/build.gradle ./booklore-api/settings.gradle /springboot-app/
COPY ./booklore-api/gradle /springboot-app/gradle
COPY ./booklore-api/src /springboot-app/src
COPY ./booklore-api/src/main/resources/application.yaml /springboot-app/src/main/resources/application.yaml
# Inject version into application.yaml using yq
ARG APP_VERSION
RUN apk add --no-cache yq && \
yq eval '.app.version = strenv(APP_VERSION)' -i /springboot-app/src/main/resources/application.yaml
RUN ./gradlew clean build
RUN gradle clean build
# Stage 3: Final image
FROM eclipse-temurin:21.0.5_11-jre-alpine
FROM eclipse-temurin:21-jre-alpine
RUN apk update && apk add nginx

View File

@@ -11,4 +11,19 @@ import org.springframework.stereotype.Component;
@Setter
public class AppProperties {
private String pathConfig;
private String version;
private RemoteAuth remoteAuth;
@Getter
@Setter
public static class RemoteAuth {
private boolean enabled;
private boolean createNewUsers;
private String headerName;
private String headerUser;
private String headerEmail;
private String headerGroups;
private String adminGroup;
}
}

View File

@@ -1,10 +1,12 @@
package com.adityachandel.booklore.config.security;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.request.UserLoginRequest;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.user.UserCreatorService;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -13,12 +15,15 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
@AllArgsConstructor
@Service
public class AuthenticationService {
private final AppProperties appProperties;
private final UserRepository userRepository;
private final UserCreatorService userCreatorService;
private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils;
@@ -34,15 +39,34 @@ public class AuthenticationService {
throw ApiError.INVALID_CREDENTIALS.createException();
}
return loginUser(user);
}
public ResponseEntity<Map<String, String>> loginRemote(String name, String username, String email, String groups) {
if (username == null || username.isEmpty()) {
throw ApiError.BAD_REQUEST.createException("Remote-User header is missing");
}
Optional<BookLoreUserEntity> user = userRepository.findByUsername(username);
if (user.isEmpty() && appProperties.getRemoteAuth().isCreateNewUsers()) {
user = Optional.of(userCreatorService.createRemoteUser(name, username, email, groups));
}
if (user.isEmpty()) {
throw ApiError.INTERNAL_SERVER_ERROR.createException("User not found and remote user creation is disabled");
}
return loginUser(user.get());
}
public ResponseEntity<Map<String, String>> loginUser(BookLoreUserEntity user) {
String accessToken = jwtUtils.generateAccessToken(user);
String refreshToken = jwtUtils.generateRefreshToken(user);
boolean isDefaultPassword = user.isDefaultPassword();
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken,
"isDefaultPassword", String.valueOf(isDefaultPassword)
"isDefaultPassword", String.valueOf(user.isDefaultPassword())
));
}

View File

@@ -36,7 +36,7 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/register", "/api/v1/auth/login", "/api/v1/auth/refresh").permitAll()
.requestMatchers("/api/v1/auth/register", "/api/v1/auth/login", "/api/v1/auth/refresh", "/api/v1/auth/remote").permitAll()
.requestMatchers("/ws/**").permitAll()
.requestMatchers("/api/v1/books/*/cover").permitAll()
.requestMatchers(

View File

@@ -1,26 +1,29 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.config.security.AuthenticationService;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.UserCreateRequest;
import com.adityachandel.booklore.model.dto.request.RefreshTokenRequest;
import com.adityachandel.booklore.model.dto.request.UserLoginRequest;
import com.adityachandel.booklore.service.user.UserCreatorService;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.Locale;
import java.util.Map;
@Slf4j
@AllArgsConstructor
@RestController
@RequestMapping("/api/v1/auth")
public class AuthenticationController {
private final AppProperties appProperties;
private final UserCreatorService userCreatorService;
private final AuthenticationService authenticationService;
@@ -40,4 +43,20 @@ public class AuthenticationController {
public ResponseEntity<Map<String, String>> refreshToken(@Valid @RequestBody RefreshTokenRequest request) {
return authenticationService.refreshToken(request.getRefreshToken());
}
@GetMapping("/remote")
public ResponseEntity<Map<String, String>> loginRemote(@RequestHeader Map<String, String> headers) {
if (!appProperties.getRemoteAuth().isEnabled()) {
throw ApiError.REMOTE_AUTH_DISABLED.createException();
}
String name = headers.get(appProperties.getRemoteAuth().getHeaderName().toLowerCase(Locale.ROOT));
String username = headers.get(appProperties.getRemoteAuth().getHeaderUser().toLowerCase(Locale.ROOT));
String email = headers.get(appProperties.getRemoteAuth().getHeaderEmail().toLowerCase(Locale.ROOT));
String groups = headers.get(appProperties.getRemoteAuth().getHeaderGroups().toLowerCase(Locale.ROOT));
log.debug("Remote-Auth: retrieved values from headers: name: {}, username: {}, email: {}, groups: {}", name, username, email, groups);
log.debug("Remote-Auth: remote auth settings: {}", appProperties.getRemoteAuth());
return authenticationService.loginRemote(name, username, email, groups);
}
}

View File

@@ -39,7 +39,8 @@ public enum ApiError {
PASSWORD_INCORRECT(HttpStatus.BAD_REQUEST, "Incorrect current password"),
PASSWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "Password must be at least 6 characters long"),
PASSWORD_SAME_AS_CURRENT(HttpStatus.BAD_REQUEST, "New password cannot be the same as the current password"),
INVALID_CREDENTIALS(HttpStatus.BAD_REQUEST, "Invalid credentials");
INVALID_CREDENTIALS(HttpStatus.BAD_REQUEST, "Invalid credentials"),
REMOTE_AUTH_DISABLED(HttpStatus.NON_AUTHORITATIVE_INFORMATION, "Remote login is disabled");
private final HttpStatus status;
private final String message;

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.service.user;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.settings.BookPreferences;
import com.adityachandel.booklore.model.dto.UserCreateRequest;
@@ -13,10 +14,12 @@ import com.adityachandel.booklore.repository.UserRepository;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@@ -25,6 +28,7 @@ import java.util.Optional;
@AllArgsConstructor
public class UserCreatorService {
private final AppProperties appProperties;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final LibraryRepository libraryRepository;
@@ -59,15 +63,48 @@ public class UserCreatorService {
user.setLibraries(new ArrayList<>(libraries));
}
ShelfEntity shelfEntity = ShelfEntity.builder()
.user(user)
.name("Favorites")
.icon("heart")
.build();
userRepository.save(user);
shelfRepository.save(shelfEntity);
createUser(user);
}
@Transactional
public BookLoreUserEntity createRemoteUser(String name, String username, String email, String groups) {
boolean isAdmin = false;
if (groups != null && appProperties.getRemoteAuth().getAdminGroup() != null) {
String groupsContent = groups.trim();
if (groupsContent.startsWith("[") && groupsContent.endsWith("]")) {
groupsContent = groupsContent.substring(1, groupsContent.length() - 1);
}
List<String> groupsList = Arrays.asList(groupsContent.split("\\s+"));
isAdmin = groupsList.contains(appProperties.getRemoteAuth().getAdminGroup());
log.debug("Remote-Auth: user {} will be admin: {}", username, isAdmin);
}
BookLoreUserEntity user = new BookLoreUserEntity();
user.setUsername(username);
user.setName(name != null ? name : username);
user.setEmail(email);
user.setDefaultPassword(false);
user.setPasswordHash(passwordEncoder.encode(RandomStringUtils.secure().nextAlphanumeric(32)));
UserPermissionsEntity permissions = new UserPermissionsEntity();
permissions.setUser(user);
permissions.setPermissionUpload(true);
permissions.setPermissionDownload(true);
permissions.setPermissionEditMetadata(true);
permissions.setPermissionEmailBook(true);
permissions.setPermissionAdmin(isAdmin);
user.setPermissions(permissions);
if (isAdmin) {
List<LibraryEntity> libraries = libraryRepository.findAll();
user.setLibraries(new ArrayList<>(libraries));
}
user.setBookPreferences(buildDefaultBookPreferences());
return createUser(user);
}
@Transactional
public void createAdminUser() {
BookLoreUserEntity user = new BookLoreUserEntity();
user.setUsername("admin");
@@ -88,16 +125,20 @@ public class UserCreatorService {
user.setPermissions(permissions);
user.setBookPreferences(buildDefaultBookPreferences());
createUser(user);
log.info("Created admin user {}", user.getUsername());
}
@Transactional
BookLoreUserEntity createUser(BookLoreUserEntity user) {
ShelfEntity shelfEntity = ShelfEntity.builder()
.user(user)
.name("Favorites")
.icon("heart")
.build();
userRepository.save(user);
user = userRepository.save(user);
shelfRepository.save(shelfEntity);
log.info("Created admin user {}", user.getUsername());
return user;
}
public boolean doesAdminUserExist() {

View File

@@ -2,6 +2,15 @@ app:
path-config: '/app/data'
version: 'v0.0.40'
remote-auth:
enabled: ${REMOTE_AUTH_ENABLED:false}
create-new-users: ${REMOTE_AUTH_CREATE_NEW_USERS:true}
header-name: ${REMOTE_AUTH_HEADER_NAME:Remote-Name}
header-user: ${REMOTE_AUTH_HEADER_USER:Remote-User}
header-email: ${REMOTE_AUTH_HEADER_EMAIL:Remote-Email}
header-groups: ${REMOTE_AUTH_HEADER_GROUPS:Remote-Groups}
admin-group: ${REMOTE_AUTH_ADMIN_GROUP}
spring:
servlet:
multipart:
@@ -39,6 +48,7 @@ spring:
logging:
level:
root: INFO
root: ${ROOT_LOG_LEVEL:INFO}
com.adityachandel.booklore: ${LOG_LEVEL:INFO}
org.apache.fontbox: ERROR
org.apache.pdfbox: ERROR

View File

@@ -31,6 +31,11 @@ export class LoginComponent {
errorMessage = '';
constructor(private authService: AuthService, private router: Router) {
this.authService.remoteLogin().subscribe({
next: () => {
this.router.navigate(['/dashboard']);
},
});
}
login(): void {

View File

@@ -26,6 +26,17 @@ export class AuthService {
);
}
remoteLogin(): Observable<any> {
return this.http.get<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/remote`).pipe(
tap((response) => {
if (response.accessToken && response.refreshToken) {
this.saveTokens(response.accessToken, response.refreshToken);
this.getRxStompService().activate();
}
})
);
}
refreshToken(): Observable<{ accessToken: string; refreshToken: string }> {
const refreshToken = this.getRefreshToken();
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {refreshToken}).pipe(