From d3627a2d3fc4d10292d6659dba4c3ca5438d3de4 Mon Sep 17 00:00:00 2001 From: "aditya.chandel" <> Date: Sat, 15 Feb 2025 21:43:06 -0700 Subject: [PATCH] Checkpoint: Authentication --- booklore-api/build.gradle | 5 ++ .../booklore/config/WebConfig.java | 18 ----- .../security/ApplicationStartupRunner.java | 42 +++++++++++ .../security/JwtAuthenticationFilter.java | 69 +++++++++++++++++ .../booklore/config/security/JwtUtils.java | 53 +++++++++++++ .../config/security/SecurityConfig.java | 58 ++++++++++++++ .../booklore/controller/UserController.java | 32 ++++++++ .../booklore/exception/ApiError.java | 6 +- .../booklore/model/dto/UserCreateRequest.java | 15 ++++ .../model/dto/request/UserLoginRequest.java | 9 +++ .../booklore/model/entity/UserEntity.java | 53 +++++++++++++ .../model/entity/UserPermissionsEntity.java | 34 +++++++++ .../booklore/repository/UserRepository.java | 15 ++++ .../booklore/service/UserService.java | 75 +++++++++++++++++++ .../V1__Create_Library_and_Book_Tables.sql | 26 ++++++- booklore-ui/src/app/app.routes.ts | 49 ++++++------ .../src/app/auth-interceptor.service.ts | 16 ++++ booklore-ui/src/app/auth.guard.ts | 13 ++++ .../src/app/core/service/auth.service.ts | 28 +++++++ .../layout-topbar/app.topbar.component.html | 7 ++ .../layout-topbar/app.topbar.component.ts | 5 ++ .../src/app/login/login.component.html | 13 ++++ .../src/app/login/login.component.scss | 27 +++++++ booklore-ui/src/app/login/login.component.ts | 34 +++++++++ booklore-ui/src/main.ts | 27 +++---- 25 files changed, 669 insertions(+), 60 deletions(-) delete mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/WebConfig.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/security/ApplicationStartupRunner.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtAuthenticationFilter.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtUtils.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/controller/UserController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserLoginRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/UserRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/UserService.java create mode 100644 booklore-ui/src/app/auth-interceptor.service.ts create mode 100644 booklore-ui/src/app/auth.guard.ts create mode 100644 booklore-ui/src/app/core/service/auth.service.ts create mode 100644 booklore-ui/src/app/login/login.component.html create mode 100644 booklore-ui/src/app/login/login.component.scss create mode 100644 booklore-ui/src/app/login/login.component.ts diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index 3f69563cb..cfda1ac06 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-quartz' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-configuration-processor' + implementation 'org.springframework.boot:spring-boot-starter-security' // Lombok compileOnly 'org.projectlombok:lombok' @@ -58,6 +59,10 @@ dependencies { annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' } hibernate { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/WebConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/WebConfig.java deleted file mode 100644 index 4003889c3..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/WebConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.adityachandel.booklore.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOrigins("*") - .allowedMethods("*") - .exposedHeaders("Content-Disposition") - .allowedHeaders("*"); - } -} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ApplicationStartupRunner.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ApplicationStartupRunner.java new file mode 100644 index 000000000..7920b5898 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ApplicationStartupRunner.java @@ -0,0 +1,42 @@ +package com.adityachandel.booklore.config.security; + +import com.adityachandel.booklore.model.entity.UserEntity; +import com.adityachandel.booklore.model.entity.UserPermissionsEntity; +import com.adityachandel.booklore.repository.UserRepository; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Slf4j +@AllArgsConstructor +@Component +public class ApplicationStartupRunner implements CommandLineRunner { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + + @Override + public void run(String... args) { + if (userRepository.findByUsername("admin").isEmpty()) { + UserEntity admin = new UserEntity(); + admin.setUsername("admin"); + admin.setPasswordHash(passwordEncoder.encode("admin123")); + admin.setName("Administrator"); + admin.setEmail("admin@example.com"); + + UserPermissionsEntity permissions = new UserPermissionsEntity(); + permissions.setUser(admin); + permissions.setPermissionUpload(true); + permissions.setPermissionDownload(true); + permissions.setPermissionEditMetadata(true); + permissions.setPermissionAdmin(true); + + admin.setPermissions(permissions); + userRepository.save(admin); + + log.info("Created admin user {}", admin.getUsername()); + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtAuthenticationFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtAuthenticationFilter.java new file mode 100644 index 000000000..4011fea8a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtAuthenticationFilter.java @@ -0,0 +1,69 @@ +package com.adityachandel.booklore.config.security; + +import com.adityachandel.booklore.model.entity.UserEntity; +import com.adityachandel.booklore.model.entity.UserPermissionsEntity; +import com.adityachandel.booklore.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@AllArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtUtils jwtUtils; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + String token = getJwtFromRequest(request); + + if (token != null && jwtUtils.validateToken(token)) { + String username = jwtUtils.extractUsername(token); + UserEntity userEntity = userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("User not found")); + List authorities = getAuthorities(userEntity.getPermissions()); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + chain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + return null; + } + + private List getAuthorities(UserPermissionsEntity permissions) { + List authorities = new ArrayList<>(); + if (permissions.isPermissionUpload()) { + authorities.add(new SimpleGrantedAuthority("ROLE_UPLOAD")); + } + if (permissions.isPermissionDownload()) { + authorities.add(new SimpleGrantedAuthority("ROLE_DOWNLOAD")); + } + if (permissions.isPermissionEditMetadata()) { + authorities.add(new SimpleGrantedAuthority("ROLE_EDIT_METADATA")); + } + return authorities; + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtUtils.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtUtils.java new file mode 100644 index 000000000..509faa960 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtUtils.java @@ -0,0 +1,53 @@ +package com.adityachandel.booklore.config.security; + +import io.jsonwebtoken.*; +import com.adityachandel.booklore.model.entity.UserEntity; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JwtUtils { + private final String secretKey = "G6u4m3g7M/b93k7m9a1h1Kw4l3D+5WqXldpl4nTjl4s="; + + public String generateToken(UserEntity user) { + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + 1000 * 60 * 60 * 10); + + return Jwts.builder() + .claim("sub", user.getUsername()) + .issuedAt(now) + .expiration(expirationDate) + .signWith(key, Jwts.SIG.HS256) + .compact(); + } + + public boolean validateToken(String token) { + return !isTokenExpired(token); + } + + public String extractUsername(String token) { + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } + + private boolean isTokenExpired(String token) { + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration() + .before(new Date()); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java new file mode 100644 index 000000000..d5f4a3838 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.adityachandel.booklore.config.security; + +import lombok.AllArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@AllArgsConstructor +@Configuration +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) // Enable CORS + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/register", "/api/v1/auth/login").permitAll() + .anyRequest().authenticated() + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:4200")); // Allow frontend origin + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type")); + configuration.setExposedHeaders(List.of("Content-Disposition")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserController.java new file mode 100644 index 000000000..d80d664ca --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserController.java @@ -0,0 +1,32 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.UserCreateRequest; +import com.adityachandel.booklore.model.dto.request.UserLoginRequest; +import com.adityachandel.booklore.service.UserService; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +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 java.util.Map; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/v1/auth") +public class UserController { + + private final UserService userService; + + @PostMapping("/register") + public ResponseEntity> registerUser(@RequestBody @Valid UserCreateRequest userCreateRequest) { + return userService.registerUser(userCreateRequest); + } + + @PostMapping("/login") + public ResponseEntity> loginUser(@RequestBody @Valid UserLoginRequest loginRequest) { + return userService.loginUser(loginRequest); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java index d28bf1c9a..7c37ac8d3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java @@ -6,7 +6,6 @@ import org.springframework.http.HttpStatus; @Getter public enum ApiError { - AUTHOR_NOT_FOUND(HttpStatus.NOT_FOUND, "Author not found with ID: %d"), BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "Book not found with ID: %d"), UNSUPPORTED_BOOK_TYPE(HttpStatus.BAD_REQUEST, "Unsupported book type for viewer settings"), INVALID_VIEWER_SETTING(HttpStatus.BAD_REQUEST, "Invalid viewer setting for the book"), @@ -26,7 +25,10 @@ public enum ApiError { METADATA_SOURCE_NOT_IMPLEMENT_OR_DOES_NOT_EXIST(HttpStatus.BAD_REQUEST, "Metadata source not implement or does not exist"), FAILED_TO_DOWNLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "Error while downloading file, bookId: %s"), INVALID_REFRESH_TYPE(HttpStatus.BAD_REQUEST, "The refresh type is invalid"), - METADATA_LOCKED(HttpStatus.FORBIDDEN, "Attempt to update locked metadata fields for book with ID: %d"); + METADATA_LOCKED(HttpStatus.FORBIDDEN, "Attempt to update locked metadata fields for book with ID: %d"), + USERNAME_ALREADY_TAKEN(HttpStatus.BAD_REQUEST, "Username already taken: %s"), + USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "User not found: %s"), + INVALID_CREDENTIALS(HttpStatus.BAD_REQUEST, "Invalid credentials"); private final HttpStatus status; private final String message; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java new file mode 100644 index 000000000..da4451253 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java @@ -0,0 +1,15 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.Data; + +@Data +public class UserCreateRequest { + private String username; + private String password; + private String name; + private String email; + + private boolean permissionUpload; + private boolean permissionDownload; + private boolean permissionEditMetadata; +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserLoginRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserLoginRequest.java new file mode 100644 index 000000000..67bb30562 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserLoginRequest.java @@ -0,0 +1,9 @@ +package com.adityachandel.booklore.model.dto.request; + +import lombok.Data; + +@Data +public class UserLoginRequest { + private String username; + private String password; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserEntity.java new file mode 100644 index 000000000..751b84959 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserEntity.java @@ -0,0 +1,53 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Entity +@Table(name = "users") +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + @Column(nullable = false) + private String name; + + @Column(unique = true) + private String email; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private UserPermissionsEntity permissions; + + @PrePersist + public void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java new file mode 100644 index 000000000..2cbf46bb4 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java @@ -0,0 +1,34 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Entity +@Table(name = "user_permissions") +public class UserPermissionsEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "user_id", nullable = false, unique = true) + private UserEntity user; + + @Column(name = "permission_upload", nullable = false) + private boolean permissionUpload = false; + + @Column(name = "permission_download", nullable = false) + private boolean permissionDownload = false; + + @Column(name = "permission_edit_metadata", nullable = false) + private boolean permissionEditMetadata = false; + + @Column(name = "permission_admin", nullable = false) + private boolean permissionAdmin; +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserRepository.java new file mode 100644 index 000000000..09557dce9 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserRepository.java @@ -0,0 +1,15 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + Optional findByEmail(String email); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/UserService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/UserService.java new file mode 100644 index 000000000..1c437f2db --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/UserService.java @@ -0,0 +1,75 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.security.JwtUtils; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.model.dto.UserCreateRequest; +import com.adityachandel.booklore.model.dto.request.UserLoginRequest; +import com.adityachandel.booklore.model.entity.UserEntity; +import com.adityachandel.booklore.model.entity.UserPermissionsEntity; +import com.adityachandel.booklore.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtils jwtUtils; + + public ResponseEntity> registerUser(UserCreateRequest request) { + Optional existingUser = userRepository.findByUsername(request.getUsername()); + if (existingUser.isPresent()) { + throw ApiError.USERNAME_ALREADY_TAKEN.createException(request.getUsername()); + } + + // Create new user entity + UserEntity userEntity = new UserEntity(); + userEntity.setUsername(request.getUsername()); + userEntity.setPasswordHash(passwordEncoder.encode(request.getPassword())); + userEntity.setName(request.getName()); + userEntity.setEmail(request.getEmail()); + + // Create permissions entity with values from the user request + UserPermissionsEntity permissions = new UserPermissionsEntity(); + permissions.setUser(userEntity); + permissions.setPermissionUpload(request.isPermissionUpload()); + permissions.setPermissionDownload(request.isPermissionDownload()); + permissions.setPermissionEditMetadata(request.isPermissionEditMetadata()); + + // Associate permissions with user + userEntity.setPermissions(permissions); + + // Save user (CascadeType.ALL ensures permissions are also saved) + userRepository.save(userEntity); + + // Generate JWT token + String token = jwtUtils.generateToken(userEntity); + + // Prepare response + Map response = new HashMap<>(); + response.put("token", token); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + public ResponseEntity> loginUser(UserLoginRequest loginRequest) { + UserEntity user = userRepository.findByUsername(loginRequest.getUsername()) + .orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername())); + + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPasswordHash())) { + throw ApiError.INVALID_CREDENTIALS.createException(); + } + + String token = jwtUtils.generateToken(user); + return ResponseEntity.ok(Map.of("token", token)); + } +} diff --git a/booklore-api/src/main/resources/db/migration/V1__Create_Library_and_Book_Tables.sql b/booklore-api/src/main/resources/db/migration/V1__Create_Library_and_Book_Tables.sql index fb56e4e9d..7a1aa8318 100644 --- a/booklore-api/src/main/resources/db/migration/V1__Create_Library_and_Book_Tables.sql +++ b/booklore-api/src/main/resources/db/migration/V1__Create_Library_and_Book_Tables.sql @@ -166,4 +166,28 @@ CREATE TABLE app_settings name VARCHAR(255) NOT NULL, val TEXT NOT NULL, UNIQUE (category, name) -); \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS users +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_permissions +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + permission_upload BOOLEAN NOT NULL DEFAULT FALSE, + permission_download BOOLEAN NOT NULL DEFAULT FALSE, + permission_edit_metadata BOOLEAN NOT NULL DEFAULT FALSE, + permission_admin BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_user_permissions_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_user_permissions_user ON user_permissions (user_id); \ No newline at end of file diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index 853f219f8..043c21d76 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -1,38 +1,35 @@ -import {Routes} from '@angular/router'; -import {PdfViewerComponent} from './book/components/pdf-viewer/pdf-viewer.component'; -import {BookBrowserComponent} from './book/components/book-browser/book-browser.component'; -import {MainDashboardComponent} from './dashboard/components/main-dashboard/main-dashboard.component'; -import {AppLayoutComponent} from './layout/component/layout-main/app.layout.component'; -import {EpubViewerComponent} from './epub-viewer/component/epub-viewer.component'; -import {SettingsComponent} from './core/component/settings/settings.component'; +import { Routes } from '@angular/router'; +import { PdfViewerComponent } from './book/components/pdf-viewer/pdf-viewer.component'; +import { BookBrowserComponent } from './book/components/book-browser/book-browser.component'; +import { MainDashboardComponent } from './dashboard/components/main-dashboard/main-dashboard.component'; +import { AppLayoutComponent } from './layout/component/layout-main/app.layout.component'; +import { EpubViewerComponent } from './epub-viewer/component/epub-viewer.component'; +import { SettingsComponent } from './core/component/settings/settings.component'; +import { LoginComponent } from './login/login.component'; +import { AuthGuard } from './auth.guard'; export const routes: Routes = [ { - path: '', component: AppLayoutComponent, + path: '', + component: AppLayoutComponent, children: [ - { - path: '', component: MainDashboardComponent, - }, - { - path: 'all-books', component: BookBrowserComponent, - }, - { - path: 'settings', component: SettingsComponent, - }, - { - path: 'library/:libraryId/books', component: BookBrowserComponent, - }, - { - path: 'shelf/:shelfId/books', component: BookBrowserComponent, - } + { path: '', component: MainDashboardComponent, canActivate: [AuthGuard] }, + { path: 'all-books', component: BookBrowserComponent, canActivate: [AuthGuard] }, + { path: 'settings', component: SettingsComponent, canActivate: [AuthGuard] }, + { path: 'library/:libraryId/books', component: BookBrowserComponent, canActivate: [AuthGuard] }, + { path: 'shelf/:shelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard] }, ] }, { path: 'pdf-viewer/book/:bookId', - component: PdfViewerComponent + component: PdfViewerComponent, + canActivate: [AuthGuard] }, { path: 'epub-viewer/book/:bookId', - component: EpubViewerComponent - } + component: EpubViewerComponent, + canActivate: [AuthGuard] + }, + { path: 'login', component: LoginComponent }, + { path: '**', redirectTo: 'login', pathMatch: 'full' } ]; diff --git a/booklore-ui/src/app/auth-interceptor.service.ts b/booklore-ui/src/app/auth-interceptor.service.ts new file mode 100644 index 000000000..caf39a577 --- /dev/null +++ b/booklore-ui/src/app/auth-interceptor.service.ts @@ -0,0 +1,16 @@ +import {HttpInterceptorFn} from '@angular/common/http'; + +export const AuthInterceptorService: HttpInterceptorFn = (req, next) => { + const token = localStorage.getItem('token'); + + if (token) { + const clonedReq = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}` + } + }); + return next(clonedReq); + } + + return next(req); +}; diff --git a/booklore-ui/src/app/auth.guard.ts b/booklore-ui/src/app/auth.guard.ts new file mode 100644 index 000000000..fc657ecc5 --- /dev/null +++ b/booklore-ui/src/app/auth.guard.ts @@ -0,0 +1,13 @@ +import {inject} from '@angular/core'; +import {CanActivateFn, Router} from '@angular/router'; + +export const AuthGuard: CanActivateFn = (route, state) => { + const router = inject(Router); + const token = localStorage.getItem('token'); + + if (!token) { + router.navigate(['/login']); + return false; + } + return true; +}; diff --git a/booklore-ui/src/app/core/service/auth.service.ts b/booklore-ui/src/app/core/service/auth.service.ts new file mode 100644 index 000000000..a65934f11 --- /dev/null +++ b/booklore-ui/src/app/core/service/auth.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private apiUrl = 'http://localhost:7050/api/v1/auth/login'; + + constructor(private http: HttpClient) {} + + login(credentials: { username: string; password: string }): Observable { + return this.http.post(this.apiUrl, credentials); + } + + saveToken(token: string): void { + localStorage.setItem('token', token); + } + + getToken(): string | null { + return localStorage.getItem('token'); + } + + logout(): void { + localStorage.removeItem('token'); + } +} diff --git a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.html b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.html index 117661df7..5c272632e 100644 --- a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.html +++ b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.html @@ -68,6 +68,13 @@ + + +
  • + +
  • diff --git a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts index bde86fa60..b27f2a672 100644 --- a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts +++ b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts @@ -113,4 +113,9 @@ export class AppTopBarComponent implements OnDestroy { navigateToSettings() { this.router.navigate(['/settings']); } + + logout() { + localStorage.removeItem('token'); + window.location.href = '/login'; + } } diff --git a/booklore-ui/src/app/login/login.component.html b/booklore-ui/src/app/login/login.component.html new file mode 100644 index 000000000..2d739c78f --- /dev/null +++ b/booklore-ui/src/app/login/login.component.html @@ -0,0 +1,13 @@ + diff --git a/booklore-ui/src/app/login/login.component.scss b/booklore-ui/src/app/login/login.component.scss new file mode 100644 index 000000000..1c9f05dc4 --- /dev/null +++ b/booklore-ui/src/app/login/login.component.scss @@ -0,0 +1,27 @@ +.login-container { + @apply flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-blue-200 to-blue-500; + + h2 { + @apply text-2xl font-bold text-gray-700 mb-4; + } + + form { + @apply bg-white p-6 rounded-2xl shadow-lg w-96 flex flex-col gap-4; + + label { + @apply text-sm font-semibold text-gray-600; + } + + input { + @apply w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400; + } + + button { + @apply w-full bg-blue-500 text-white py-2 rounded-lg font-semibold hover:bg-blue-600 transition duration-300; + } + + .error { + @apply text-red-500 text-sm font-medium mt-2; + } + } +} diff --git a/booklore-ui/src/app/login/login.component.ts b/booklore-ui/src/app/login/login.component.ts new file mode 100644 index 000000000..9f58f99c3 --- /dev/null +++ b/booklore-ui/src/app/login/login.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import {AuthService} from '../core/service/auth.service'; +import {Router} from '@angular/router'; +import {FormsModule} from '@angular/forms'; +import {NgIf} from '@angular/common'; + +@Component({ + selector: 'app-login', + imports: [ + FormsModule, + NgIf + ], + templateUrl: './login.component.html', + styleUrl: './login.component.scss' +}) +export class LoginComponent { + username = ''; + password = ''; + errorMessage = ''; + + constructor(private authService: AuthService, private router: Router) {} + + login(): void { + this.authService.login({ username: this.username, password: this.password }).subscribe({ + next: (response) => { + this.authService.saveToken(response.token); + this.router.navigate(['/all-books']); + }, + error: () => { + this.errorMessage = 'Invalid username or password'; + } + }); + } +} diff --git a/booklore-ui/src/main.ts b/booklore-ui/src/main.ts index 10eb71b00..1e023ffc2 100644 --- a/booklore-ui/src/main.ts +++ b/booklore-ui/src/main.ts @@ -1,20 +1,21 @@ -import {provideHttpClient} from '@angular/common/http'; -import {DialogService} from 'primeng/dynamicdialog'; -import {ConfirmationService, MessageService} from 'primeng/api'; -import {RxStompService} from './app/shared/websocket/rx-stomp.service'; -import {rxStompServiceFactory} from './app/shared/websocket/rx-stomp-service-factory'; -import {provideRouter, RouteReuseStrategy} from '@angular/router'; -import {CustomReuseStrategy} from './app/custom-reuse-strategy'; -import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; -import {providePrimeNG} from 'primeng/config'; -import {bootstrapApplication} from '@angular/platform-browser'; -import {AppComponent} from './app/app.component'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { DialogService } from 'primeng/dynamicdialog'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { RxStompService } from './app/shared/websocket/rx-stomp.service'; +import { rxStompServiceFactory } from './app/shared/websocket/rx-stomp-service-factory'; +import { provideRouter, RouteReuseStrategy } from '@angular/router'; +import { CustomReuseStrategy } from './app/custom-reuse-strategy'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { providePrimeNG } from 'primeng/config'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; import Aura from '@primeng/themes/aura'; -import {routes} from './app/app.routes'; +import { routes } from './app/app.routes'; +import {AuthInterceptorService} from './app/auth-interceptor.service'; bootstrapApplication(AppComponent, { providers: [ - provideHttpClient(), + provideHttpClient(withInterceptors([AuthInterceptorService])), provideRouter(routes), DialogService, MessageService,