diff --git a/src/main/java/com/slotline/user/AuthController.java b/src/main/java/com/slotline/user/AuthController.java new file mode 100644 index 0000000..814f9ac --- /dev/null +++ b/src/main/java/com/slotline/user/AuthController.java @@ -0,0 +1,42 @@ +package com.slotline.user; + +import com.slotline.user.dto.LoginRequest; +import com.slotline.user.dto.RegisterRequest; +import com.slotline.user.dto.TokenResponse; +import com.slotline.user.dto.UserResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@Tag(name = "Authentication") +@SecurityRequirements +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Register a new USER account") + public UserResponse register(@Valid @RequestBody RegisterRequest request) { + return authService.register(request); + } + + @PostMapping("/login") + @Operation(summary = "Exchange credentials for a JWT bearer token") + public TokenResponse login(@Valid @RequestBody LoginRequest request) { + return authService.login(request); + } +} diff --git a/src/main/java/com/slotline/user/AuthService.java b/src/main/java/com/slotline/user/AuthService.java new file mode 100644 index 0000000..3b471a1 --- /dev/null +++ b/src/main/java/com/slotline/user/AuthService.java @@ -0,0 +1,62 @@ +package com.slotline.user; + +import com.slotline.common.InvalidCredentialsException; +import com.slotline.common.UsernameTakenException; +import com.slotline.config.SlotlineProperties; +import com.slotline.organization.Organization; +import com.slotline.organization.OrganizationService; +import com.slotline.security.JwtService; +import com.slotline.user.dto.LoginRequest; +import com.slotline.user.dto.RegisterRequest; +import com.slotline.user.dto.TokenResponse; +import com.slotline.user.dto.UserResponse; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Service +public class AuthService { + + private final UserRepository users; + private final OrganizationService organizations; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final SlotlineProperties properties; + + public AuthService(UserRepository users, OrganizationService organizations, PasswordEncoder passwordEncoder, + JwtService jwtService, SlotlineProperties properties) { + this.users = users; + this.organizations = organizations; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + this.properties = properties; + } + + @Transactional + public UserResponse register(RegisterRequest request) { + if (users.existsByUsername(request.username())) { + throw new UsernameTakenException(request.username()); + } + Organization organization = resolveOrganization(request.organizationSlug()); + User user = new User(organization.getId(), request.username(), passwordEncoder.encode(request.password()), + Role.USER, Instant.now()); + users.save(user); + return new UserResponse(user.getId(), user.getUsername(), user.getRole(), user.getOrganizationId()); + } + + @Transactional(readOnly = true) + public TokenResponse login(LoginRequest request) { + User user = users.findByUsername(request.username()) + .filter(candidate -> passwordEncoder.matches(request.password(), candidate.getPasswordHash())) + .orElseThrow(InvalidCredentialsException::new); + String token = jwtService.issue(user.getId(), user.getUsername(), user.getRole(), user.getOrganizationId()); + return new TokenResponse(token, "Bearer", properties.getJwt().getExpiration().toSeconds()); + } + + private Organization resolveOrganization(String slug) { + String target = (slug == null || slug.isBlank()) ? OrganizationService.defaultSlug : slug; + return organizations.requireBySlug(target); + } +} diff --git a/src/main/java/com/slotline/user/dto/LoginRequest.java b/src/main/java/com/slotline/user/dto/LoginRequest.java new file mode 100644 index 0000000..9f95cc7 --- /dev/null +++ b/src/main/java/com/slotline/user/dto/LoginRequest.java @@ -0,0 +1,8 @@ +package com.slotline.user.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank String username, + @NotBlank String password) { +} diff --git a/src/main/java/com/slotline/user/dto/RegisterRequest.java b/src/main/java/com/slotline/user/dto/RegisterRequest.java new file mode 100644 index 0000000..1a99568 --- /dev/null +++ b/src/main/java/com/slotline/user/dto/RegisterRequest.java @@ -0,0 +1,14 @@ +package com.slotline.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank @Size(min = 3, max = 64) String username, + @NotBlank @Size(min = 8, max = 72) String password, + @Size(max = 64) String organizationSlug) { + + public RegisterRequest(String username, String password) { + this(username, password, null); + } +} diff --git a/src/main/java/com/slotline/user/dto/TokenResponse.java b/src/main/java/com/slotline/user/dto/TokenResponse.java new file mode 100644 index 0000000..ddcc7d3 --- /dev/null +++ b/src/main/java/com/slotline/user/dto/TokenResponse.java @@ -0,0 +1,4 @@ +package com.slotline.user.dto; + +public record TokenResponse(String token, String tokenType, long expiresInSeconds) { +} diff --git a/src/main/java/com/slotline/user/dto/UserResponse.java b/src/main/java/com/slotline/user/dto/UserResponse.java new file mode 100644 index 0000000..0c25bb1 --- /dev/null +++ b/src/main/java/com/slotline/user/dto/UserResponse.java @@ -0,0 +1,6 @@ +package com.slotline.user.dto; + +import com.slotline.user.Role; + +public record UserResponse(Long id, String username, Role role, Long organizationId) { +}