Implement enhanced login functionality with JWT, role-based permissions, and frontend integration

Added JWT-based login dialog with form validation and secure token handling on the frontend. Updated backend with role-based access control, privilege management, and refined security configurations. Extended database schema for user privileges and permissions.
This commit is contained in:
2025-05-30 23:41:13 +02:00
parent 20dcebbab9
commit 07646e8c42
26 changed files with 572 additions and 59 deletions
@@ -0,0 +1,36 @@
package com.alttd.altitudeweb.services.limits;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class InMemoryRateLimiterService {
private final Map<String, RequestCounter> limiters = new ConcurrentHashMap<>();
public boolean tryAcquire(String key, int limit, Duration duration) {
RequestCounter counter = limiters.computeIfAbsent(key, k -> new RequestCounter());
return counter.tryAcquire(limit, duration);
}
public Duration getNextResetTime(String key, Duration duration) {
RequestCounter counter = limiters.get(key);
if (counter == null) {
return Duration.ZERO;
}
return counter.getNextResetTime(duration);
}
public int getRemainingTokens(String key, int limit, Duration duration) {
RequestCounter counter = limiters.get(key);
if (counter == null) {
return limit;
}
return Math.max(0, limit - counter.getCurrentCount(duration));
}
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.services.limits;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int limit() default 20;
long timeValue() default 60;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String key() default "";
}
@@ -0,0 +1,83 @@
package com.alttd.altitudeweb.services.limits;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
import java.time.Duration;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitAspect {
private final InMemoryRateLimiterService rateLimiterService;
@Around("""
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
|| @within(com.alttd.altitudeweb.services.limits.RateLimit)""")
public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
return joinPoint.proceed();
}
HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse();
String clientIp = request.getRemoteAddr();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
if (rateLimit == null) {
rateLimit = method.getDeclaringClass().getAnnotation(RateLimit.class);
}
if (rateLimit == null) {
return joinPoint.proceed();
}
int limit = rateLimit.limit();
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
String customKey = rateLimit.key();
String key = clientIp + "-" + (customKey.isEmpty() ? method.getName() : customKey);
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
if (allowed) {
if (response != null) {
response.setHeader("X-Rate-Limit-Limit", String.valueOf(limit));
response.setHeader("X-Rate-Limit-Remaining",
String.valueOf(rateLimiterService.getRemainingTokens(key, limit, duration)));
}
return joinPoint.proceed();
} else {
log.warn("Rate limit exceeded for IP: {}, endpoint: {}", clientIp, request.getRequestURI());
Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration);
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.header("X-Rate-Limit-Limit", String.valueOf(limit))
.header("X-Rate-Limit-Remaining", "0")
.header("Retry-After", String.valueOf(nextResetTime.getSeconds()))
.header("Access-Control-Expose-Headers", "Retry-After, X-Rate-Limit-Limit, X-Rate-Limit-Remaining")
.body(String.format("Rate limit exceeded. Try again in %d seconds.", nextResetTime.getSeconds()));
}
}
}
@@ -0,0 +1,57 @@
package com.alttd.altitudeweb.services.limits;
import java.time.Duration;
import java.time.Instant;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
public class RequestCounter {
private final Queue<Instant> requestTimestamps = new ConcurrentLinkedQueue<>();
/**
* Attempts to acquire a permit for a request within the specified rate limit.
* This method checks if the number of requests made within the given duration
* is less than the specified limit. If the limit has not been exceeded, the
* current request is recorded and the method will return true. Otherwise, it
* will return false.
*
* @param limit the maximum number of requests allowed within the specified duration
* @param duration the time window during which requests are counted against the limit
* @return true if the request is successfully acquired without exceeding the rate limit, false otherwise
*/
public synchronized boolean tryAcquire(int limit, Duration duration) {
Instant currentTime = Instant.now();
Instant cutoffTime = currentTime.minus(duration);
while (!requestTimestamps.isEmpty() && requestTimestamps.peek().isBefore(cutoffTime)) {
requestTimestamps.poll();
}
if (requestTimestamps.size() < limit) {
requestTimestamps.add(currentTime);
return true;
}
return false;
}
public int getCurrentCount(Duration duration) {
Instant cutoffTime = Instant.now().minus(duration);
return (int) requestTimestamps.stream()
.filter(timestamp -> !timestamp.isBefore(cutoffTime))
.count();
}
public Duration getNextResetTime(Duration duration) {
if (requestTimestamps.isEmpty()) {
return Duration.ZERO;
}
Instant oldestTimestamp = requestTimestamps.peek();
Instant resetTime = oldestTimestamp.plus(duration);
Duration timeUntilReset = Duration.between(Instant.now(), resetTime);
return timeUntilReset.isNegative() ? Duration.ZERO : timeUntilReset;
}
}
@@ -0,0 +1,27 @@
package com.alttd.altitudeweb.services.user;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String uuid) throws UsernameNotFoundException {
try {
//Validate uuid
UUID.fromString(uuid);
return new User(uuid, "", Collections.emptyList());
} catch (IllegalArgumentException e) {
throw new UsernameNotFoundException("Invalid UUID format: " + uuid);
}
}
}