Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ FROM eclipse-temurin:21-jdk-alpine

COPY --from=build ./target/NoteHub-2.1.jar app.jar

ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-Xms256m", "-Xmx768m", "-jar", "app.jar"]
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.16.0</version>
</dependency>
<dependency>
<groupId>nl.basjes.parse.useragent</groupId>
<artifactId>yauaa</artifactId>
<version>7.28.1</version>
</dependency>
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>5.0.2</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>
<plugins>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package br.com.notehub.application.controller.auth;

import br.com.notehub.adapter.producer.MailProducer;
import br.com.notehub.application.dto.request.token.AuthChangeREQ;
import br.com.notehub.application.dto.request.token.AuthREQ;
import br.com.notehub.application.dto.request.token.OAuth2GoogleREQ;
import br.com.notehub.application.dto.request.token.OAuthGitHubREQ;
import br.com.notehub.application.dto.request.token.*;
import br.com.notehub.application.dto.response.token.AuthRES;
import br.com.notehub.application.dto.response.token.SessionRES;
import br.com.notehub.domain.token.Token;
import br.com.notehub.domain.token.TokenService;
import br.com.notehub.domain.user.UserService;
import com.auth0.jwt.JWT;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
Expand All @@ -24,6 +24,9 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/v1/auth")
@Tag(name = "Auth Controller", description = "Endpoints for authentication and authorization")
Expand Down Expand Up @@ -172,4 +175,42 @@ public ResponseEntity<Void> requestEmailChange(
return ResponseEntity.status(HttpStatus.ACCEPTED).build();
}

@Operation(
summary = "Fetch all account sessions",
description = "Get a full description of all connections across differents devices.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Sessions retrieves successfully."),
@ApiResponse(responseCode = "400", description = "Invalid token.", content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "403", description = "Null token.", content = @Content(examples = {})),
@ApiResponse(responseCode = "500", description = "Internal server error.", content = @Content(examples = {}))
})
@PostMapping("/sessions")
public ResponseEntity<List<SessionRES>> findAllSessions(
@Parameter(hidden = true) @RequestHeader("Authorization") String accessToken,
@Valid @RequestBody AuthSessionsREQ dto
) {
UUID idFromToken = accessToken != null
? UUID.fromString(JWT.decode(accessToken.replace("Bearer ", "")).getSubject())
: null;
List<Token> tokens = service.getAllSessions(idFromToken, dto.password());
return ResponseEntity.status(HttpStatus.OK).body(tokens.stream().map(SessionRES::new).toList());
}

@Operation(
summary = "Disconnect session",
description = "Disconnect a session via token id. The session ID is only exposed after a password-verified fetch via POST /sessions, ensuring that only authenticated users with knowledge of their own sessions can perform this action."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Session disconnected successfully."),
@ApiResponse(responseCode = "404", description = "Session not found.", content = @Content(examples = {})),
@ApiResponse(responseCode = "500", description = "Internal server error.", content = @Content(examples = {}))
})
@DeleteMapping("/session/{id}")
public ResponseEntity<Void> disconnectSession(
@PathVariable("id") UUID id
) {
service.disconnect(id);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package br.com.notehub.application.dto.request.token;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record AuthSessionsREQ(
@NotBlank(message = "Não pode ser vazio")
@Size(min = 4, max = 255, message = "Tamanho inválido")
String password
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package br.com.notehub.application.dto.response.token;

import br.com.notehub.domain.token.Token;

import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.UUID;

public record SessionRES(
UUID id,
UUID device,
String createdAt,
String ip,
String deviceType,
String deviceBrand,
String deviceModel,
String os,
String browser,
String country,
String region,
String city
) {
public SessionRES(Token token) {
this(
token.getId(),
token.getDevice(),
token.getCreatedAt().atZone(ZoneId.of("America/Sao_Paulo")).format(DateTimeFormatter.ofPattern("d/M/yy HH:mm", Locale.of("pt-BR"))),
token.getIp(),
token.getDeviceType(),
token.getDeviceBrand(),
token.getDeviceModel(),
token.getOs(),
token.getBrowser(),
token.getCountry(),
token.getRegion(),
token.getCity()
);
}
}
97 changes: 97 additions & 0 deletions src/main/java/br/com/notehub/application/geoip/GeoIpService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package br.com.notehub.application.geoip;

import br.com.notehub.domain.token.Token;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.model.CityResponse;
import nl.basjes.parse.useragent.UserAgent;
import nl.basjes.parse.useragent.UserAgentAnalyzer;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;

@Service
public class GeoIpService {

private final UserAgentAnalyzer uaAnalyzer;
private final DatabaseReader geoReader;

public GeoIpService() throws IOException {
this.uaAnalyzer = UserAgentAnalyzer.newBuilder()
.hideMatcherLoadStats()
.withCache(1000)
.build();
InputStream stream = getClass()
.getClassLoader()
.getResourceAsStream("geoip/GeoLite2-City.mmdb");
this.geoReader = new DatabaseReader.Builder(stream).build();
}

private String normalizeDeviceClass(String deviceClass) {
if (deviceClass == null) return "unknown";
return switch (deviceClass.toLowerCase()) {
case "phone", "mobile" -> "Mobile";
case "tablet" -> "Tablet";
default -> "Desktop";
};
}

private String sanitize(String value) {
if (value == null || value.isBlank() || value.equals("??") || value.equalsIgnoreCase("unknown")) return "unknown";
return value;
}

private String normalizeOs(String osName) {
if (osName == null) return "unknown";
String os = osName.toLowerCase();
if (os.contains("windows")) return "Windows";
if (os.contains("mac") || os.contains("os x")) return "macOS";
if (os.contains("android")) return "Android";
if (os.contains("ios") || os.contains("iphone") || os.contains("ipad")) return "iOS";
if (os.contains("linux")) return "Linux";
if (os.contains("chrome os")) return "ChromeOS";
return osName;
}

private void parseUserAgent(Token token, String userAgent) {
UserAgent agent = uaAnalyzer.parse(userAgent);
String browser = agent.getValue(UserAgent.AGENT_NAME);
String browserVersion = agent.getValue(UserAgent.AGENT_VERSION_MAJOR);
token.setBrowser(browser + (browserVersion != null ? " " + browserVersion : ""));
token.setOs(normalizeOs(agent.getValue(UserAgent.OPERATING_SYSTEM_NAME)));
String deviceClass = agent.getValue(UserAgent.DEVICE_CLASS);
String brand = agent.getValue(UserAgent.DEVICE_BRAND);
String model = agent.getValue(UserAgent.DEVICE_NAME);
token.setDeviceBrand(sanitize(brand));
token.setDeviceModel(sanitize(model));
token.setDeviceType(normalizeDeviceClass(deviceClass));
}

private void parseLocation(Token token, String ip) {
try {
if (ip.equals("127.0.0.1") || ip.equals("0:0:0:0:0:0:0:1")) {
token.setIp("local");
token.setCountry("unknown");
token.setRegion("unknown");
token.setCity("unknown");
return;
}
InetAddress address = InetAddress.getByName(ip);
CityResponse response = geoReader.city(address);
token.setCountry(response.country().name());
token.setRegion(response.mostSpecificSubdivision().name());
token.setCity(response.city().name());
} catch (Exception e) {
token.setCountry("unknown");
token.setRegion("unknown");
token.setCity("unknown");
}
}

public void enrichToken(Token token, String userAgent, String ip) {
parseUserAgent(token, userAgent);
parseLocation(token, ip);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import br.com.notehub.application.dto.oauth.OAuthResponse;
import br.com.notehub.application.dto.response.token.AuthRES;
import br.com.notehub.application.geoip.GeoIpService;
import br.com.notehub.application.oauth.OAuthFacade;
import br.com.notehub.domain.token.Token;
import br.com.notehub.domain.token.TokenRepository;
Expand Down Expand Up @@ -43,6 +44,7 @@ public class TokenServiceImpl implements TokenService {
private String secret;

private final OAuthFacade oAuthFacade;
private final GeoIpService geoService;
private final TokenRepository repository;
private final UserRepository userRepository;
private final PasswordEncoder encoder;
Expand All @@ -69,12 +71,29 @@ private UUID validateRefreshToken(HttpServletRequest request) {
}
}

private boolean isValidIp(String ip) {
return ip != null && !ip.isBlank() && !"unknown".equalsIgnoreCase(ip);
}

private String getClientIp(HttpServletRequest request) {
String ip;
ip = request.getHeader("Fly-Client-IP");
if (isValidIp(ip)) return ip;
ip = request.getHeader("X-Forwarded-For");
if (isValidIp(ip)) return ip.split(",")[0].trim();
ip = request.getHeader("X-Real-IP");
if (isValidIp(ip)) return ip;
return request.getRemoteAddr();
}

private Token generateRefreshToken(HttpServletRequest request, User user) {
String ip = request.getRemoteAddr();
String ip = getClientIp(request);
String agent = request.getHeader("User-Agent");
UUID device = validateDevice(request);
Instant expiresAt = getExpirationTime("refresh");
return new Token(user, ip, agent, device, expiresAt);
Token token = new Token(user, ip, agent, device, expiresAt);
geoService.enrichToken(token, agent, ip);
return token;
}

private void validateInternalHost(Host host) {
Expand Down Expand Up @@ -259,6 +278,20 @@ public void logout(HttpServletRequest request) {
repository.delete(token);
}

@Override
public List<Token> getAllSessions(UUID uId, String password) {
User user = userRepository.findById(uId).orElseThrow(EntityNotFoundException::new);
boolean matches = encoder.matches(password, user.getPassword());
if (!matches) throw new BadCredentialsException("password");
return repository.findAllByUserId(uId);
}

@Override
public void disconnect(UUID id) {
Token token = repository.findById(id).orElseThrow(EntityNotFoundException::new);
repository.delete(token);
}

@Override
public void disconnectAll(HttpServletRequest request, boolean keepCurrentSession, String email) {
List<Token> connections = repository.findAllByUserEmail(email);
Expand Down
11 changes: 8 additions & 3 deletions src/main/java/br/com/notehub/domain/token/Token.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,17 @@ public class Token {
private User user;

private Instant createdAt = LocalDateTime.now().toInstant(ZoneOffset.of("-03:00"));

private String ip;

private UUID device;

private String deviceType;
private String deviceBrand;
private String deviceModel;
private String os;
private String browser;
private String agent;
private String country;
private String region;
private String city;

private Instant expiresAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public interface TokenRepository extends JpaRepository<Token, UUID> {

Optional<Token> findByDevice(UUID id);

List<Token> findAllByUserId(UUID id);

List<Token> findAllByUserEmail(String email);

@Query("SELECT t FROM Token t WHERE t.expiresAt < :now")
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/br/com/notehub/domain/token/TokenService.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package br.com.notehub.domain.token;

import br.com.notehub.application.dto.response.token.AuthRES;
import br.com.notehub.application.dto.response.token.SessionRES;
import br.com.notehub.domain.user.User;
import com.auth0.jwt.exceptions.TokenExpiredException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

@Service
public interface TokenService {
Expand Down Expand Up @@ -36,6 +39,10 @@ public interface TokenService {

void logout(HttpServletRequest request);

List<Token> getAllSessions(UUID uId, String password);

void disconnect(UUID id);

void disconnectAll(HttpServletRequest request, boolean keepCurrentSession, String email);

void cleanExpiredTokens();
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application-dev.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
geoip.database.path=src/main/resources/geoip/GeoLite2-City.mmdb

server.error.include-stacktrace=never

spring.flyway.enabled=false
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
geoip.database.path=src/main/resources/geoip/GeoLite2-City.mmdb

spring.data.web.pageable.max-page-size=6660

spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.PostgreSQLDialect
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application-test.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
geoip.database.path=src/main/resources/geoip/GeoLite2-City.mmdb

server.error.include-stacktrace=never

spring.flyway.enabled=false
Expand Down
Loading
Loading