Implement authorization controller and Chat UI

Closes #2 (closed)

Merge request reports

Loading
+115 −0
Changes for src/main/java/org/etsi/osl/mcp/backend/AuthController.java: 115 added lines, 0 removed lines.
Original line number Diff line number Diff line
package org.etsi.osl.mcp.backend;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@RestController
public class AuthController {

    @Value("${spring.security.oauth2.client.provider.keycloak.issuer-uri:}")
    private String issuerUri;

    @GetMapping("/api/user")
    public Map<String, Object> getUserInfo(Authentication authentication,
            @RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient authorizedClient) {
        Map<String, Object> userInfo = new HashMap<>();
        
        if (authentication != null && authentication.isAuthenticated()) {
            userInfo.put("authenticated", true);
            userInfo.put("name", authentication.getName());
            
            String username = authentication.getName();
            if (authentication.getPrincipal() instanceof OidcUser oidcUser) {
                userInfo.put("email", oidcUser.getEmail());
                if (oidcUser.getPreferredUsername() != null) {
                    username = oidcUser.getPreferredUsername();
                    userInfo.put("preferredUsername", oidcUser.getPreferredUsername());
                }
            }
            userInfo.put("username", username);
            
            if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
                userInfo.put("access_token", authorizedClient.getAccessToken().getTokenValue());
                userInfo.put("token", authorizedClient.getAccessToken().getTokenValue());
            }
        } else {
            userInfo.put("authenticated", false);
        }
        
        return userInfo;
    }

    @PostMapping("/api/logout")
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        performLogout(request, response, authentication);
    }
    
    @GetMapping("/api/logout")
    public void logoutGet(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        performLogout(request, response, authentication);
    }
    
    private void performLogout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        if (authentication != null) {
            new SecurityContextLogoutHandler().logout(request, null, authentication);
        }
        
        if (request.getSession(false) != null) {
            request.getSession().invalidate();
        }
        
        String logoutUrl = buildKeycloakLogoutUrl(request, authentication);
        response.sendRedirect(logoutUrl);
    }
    
    private String buildKeycloakLogoutUrl(HttpServletRequest request, Authentication authentication) {
        String keycloakIssuer = issuerUri;
        
        if (authentication != null && authentication.getPrincipal() instanceof OidcUser oidcUser) {
            if (oidcUser.getIssuer() != null) {
                keycloakIssuer = oidcUser.getIssuer().toString();
            }
        }
        
        if (keycloakIssuer == null || keycloakIssuer.isEmpty()) {
            return "/welcome.html";
        }
        
        String baseUrl = request.getScheme() + "://" + request.getServerName();
        if ((request.getScheme().equals("http") && request.getServerPort() != 80) ||
            (request.getScheme().equals("https") && request.getServerPort() != 443)) {
            baseUrl += ":" + request.getServerPort();
        }
        
        String postLogoutRedirectUri = baseUrl + "/welcome.html";
        
        String keycloakLogoutUrl = keycloakIssuer + "/protocol/openid-connect/logout";
        
        return UriComponentsBuilder
            .fromUriString(keycloakLogoutUrl)
            .queryParam("post_logout_redirect_uri", postLogoutRedirectUri)
            .build()
            .toUriString();
    }

    @GetMapping("/api/health")
    public Map<String, String> health() {
        Map<String, String> response = new HashMap<>();
        response.put("status", "UP");
        return response;
    }
}
+53 −2
Changes for src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java: 53 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -7,19 +7,70 @@ import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.http.HttpStatus;

@Configuration
@EnableWebSecurity
@Profile("!testing")
public class WebSecurityConfigKeycloak {


  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
      return http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
      return http
          .authorizeHttpRequests(auth -> auth
              // Public static resources
              .requestMatchers("/", "/index.html", "/styles.css", "/robot.svg", "/favicon.ico").permitAll()
              .requestMatchers("/api/health").permitAll()
              .requestMatchers("/api/user").permitAll()
              .requestMatchers("/api/logout").permitAll()
              .requestMatchers("/logout").permitAll()
              .requestMatchers("/ask", "/api/**").authenticated()
              .anyRequest().permitAll()
          )
          // OAuth2 Login for browser-based authentication
          .oauth2Login(oauth2 -> oauth2
              .defaultSuccessUrl("/", true)
          )
          // Logout configuration
          .logout(logout -> logout
              .logoutUrl("/logout")
              .logoutSuccessUrl("/")
              .invalidateHttpSession(true)
              .deleteCookies("JSESSIONID")
          )
          .oauth2Client(Customizer.withDefaults())
          .oauth2ResourceServer(oauth2 -> oauth2
              .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
              .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
          )
          .exceptionHandling(exception -> exception
              .defaultAuthenticationEntryPointFor(
                  new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                  new AntPathRequestMatcher("/ask")
              )
          )
          .sessionManagement(session -> session
              .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
          )
          .csrf(CsrfConfigurer::disable)
          .build();
  }

  @Bean
  public JwtAuthenticationConverter jwtAuthenticationConverter() {
      JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
      grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
      grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

      JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
      jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
      return jwtAuthenticationConverter;
  }
}
+307 −0
Changes for src/main/resources/public/css/chat.css: 307 added lines, 0 removed lines.
Original line number Diff line number Diff line
/* OpenSlice Chat Assistant - Chat Page Styles */

body {
    font-family: "Open Sans", sans-serif;
    padding-top: 60px;
    padding-bottom: 20px;
    background-color: white;
}

/* Navbar */
.navbar-default {
    background-color: rgba(245, 245, 245, 0.93);
    border-bottom: 1px solid #e7e7e7;
}

.navbar-brand img {
    height: 25px;
}

/* Chat Container */
.chat-container {
    max-width: 900px;
    margin: 20px auto;
    background: white;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    overflow: hidden;
}

/* Chat Header */
.chat-header {
    background-color: #7EADDB;
    color: white;
    padding: 20px;
    border-bottom: 1px solid #6589AB;
}

.chat-header h4 {
    margin: 0;
    font-weight: 600;
}

.chat-header .online-indicator {
    display: inline-block;
    width: 8px;
    height: 8px;
    background-color: #5cb85c;
    border-radius: 50%;
    margin-right: 5px;
}

/* Chat Messages */
.chat-messages {
    height: 500px;
    overflow-y: auto;
    padding: 20px;
    background-color: #f9f9f9;
}

.message {
    margin-bottom: 20px;
    display: flex;
    align-items: flex-start;
    clear: both;
}

.message.incoming {
    flex-direction: row;
    justify-content: flex-start;
    padding-right: 80px;
}

.message.outgoing {
    flex-direction: row-reverse;
    justify-content: flex-start;
    padding-left: 80px;
}

.message .avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    background-color: #7EADDB;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: bold;
    flex-shrink: 0;
}

.message.incoming .avatar {
    margin-right: 12px;
}

.message.outgoing .avatar {
    margin-left: 12px;
    background-color: #6589AB;
}

.message-wrapper {
    display: flex;
    flex-direction: column;
    max-width: 66%;
    min-width: fit-content;
}

.message.incoming .message-wrapper {
    align-items: flex-start;
}

.message.outgoing .message-wrapper {
    align-items: flex-end;
}

.message-content {
    max-width: 100%;
    padding: 12px 16px;
    border-radius: 8px;
    word-wrap: break-word;
    white-space: pre-wrap;
    line-height: 1.5;
}

.message-content p {
    margin: 0;
}

.message-content p:last-child {
    margin-bottom: 0;
}

.message-content p + p {
    margin-top: 10px;
}

.message-content code {
    background-color: rgba(0, 0, 0, 0.05);
    padding: 2px 4px;
    border-radius: 3px;
    font-family: monospace;
    font-size: 13px;
}

.message-content pre {
    background-color: rgba(0, 0, 0, 0.05);
    padding: 10px;
    border-radius: 4px;
    overflow-x: auto;
    margin: 10px 0;
}

.message-content pre code {
    background-color: transparent;
    padding: 0;
}

.message-content ul, .message-content ol {
    margin: 10px 0;
    padding-left: 20px;
}

.message-content li {
    margin: 5px 0;
}

.message.incoming .message-content {
    background-color: white;
    border: 1px solid #e0e0e0;
    color: #333;
}

.message.outgoing .message-content {
    background-color: #7EADDB;
    color: white;
}

.message.outgoing .message-content code {
    background-color: rgba(255, 255, 255, 0.2);
}

.message.outgoing .message-content pre {
    background-color: rgba(255, 255, 255, 0.2);
}

.message-time {
    font-size: 11px;
    color: #999;
    margin-top: 5px;
    text-align: right;
}

.message.incoming .message-time {
    text-align: left;
}

/* Chat Input */
.chat-input {
    padding: 20px;
    background-color: white;
    border-top: 1px solid #e0e0e0;
}

.chat-input .input-group {
    display: flex;
}

.chat-input textarea {
    flex: 1;
    resize: none;
    border: 1px solid #ddd;
    border-radius: 4px 0 0 4px;
    padding: 10px;
    font-size: 14px;
    min-height: 45px;
}

.chat-input button {
    background-color: #7EADDB;
    border: none;
    color: white;
    padding: 0 25px;
    border-radius: 0 4px 4px 0;
    font-weight: 600;
    transition: background-color 0.3s;
}

.chat-input button:hover {
    background-color: #6589AB;
}

/* Tool Execution */
.tool-execution {
    background-color: #f0f8ff;
    border-left: 3px solid #7EADDB;
    padding: 10px;
    margin-top: 8px;
    border-radius: 4px;
    font-family: monospace;
    font-size: 12px;
}

.tool-name {
    font-weight: bold;
    color: #6589AB;
}

/* Typing Indicator */
.typing-indicator {
    display: none;
    margin-bottom: 20px;
}

.typing-indicator.active {
    display: flex;
}

.typing-indicator .avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    background-color: #7EADDB;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: bold;
    flex-shrink: 0;
    margin-right: 12px;
}

.typing-indicator .dots {
    background-color: white;
    border: 1px solid #e0e0e0;
    padding: 12px 20px;
    border-radius: 8px;
    display: flex;
    align-items: center;
    gap: 6px;
}

.typing-indicator .dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background-color: #7EADDB;
    animation: typing 1.4s infinite;
}

.typing-indicator .dot:nth-child(2) {
    animation-delay: 0.2s;
}

.typing-indicator .dot:nth-child(3) {
    animation-delay: 0.4s;
}

@keyframes typing {
    0%, 60%, 100% { 
        transform: translateY(0); 
        opacity: 0.4; 
    }
    30% { 
        transform: translateY(-10px); 
        opacity: 1; 
    }
}
+110 −0
Changes for src/main/resources/public/css/welcome.css: 110 added lines, 0 removed lines.
Original line number Diff line number Diff line
/* OpenSlice Chat Assistant - Welcome Page Styles */

body {
    font-family: 'Open Sans', sans-serif;
    margin: 0;
    padding: 60px 0 40px 0;
    min-height: 100vh;
    background-color: white;
}

/* Navbar */
.navbar-default {
    background-color: rgba(245, 245, 245, 0.93);
    border-bottom: 1px solid #e7e7e7;
}

.navbar-brand img {
    height: 25px;
}

/* Welcome Section */
.welcome-section {
    background: linear-gradient(135deg, #989DC3 0%, #7EADDB 100%);
    padding: 80px 0;
    text-align: center;
    color: #FCFCFF;
    margin-top: 0;
    min-height: 500px;
}

.welcome-section h1 {
    font-size: 48px;
    font-weight: 300;
    margin-bottom: 20px;
    text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}

.welcome-section .subtitle {
    font-size: 22px;
    margin-bottom: 30px;
    font-weight: 300;
}

.login-message {
    background: rgba(255, 255, 255, 0.95);
    color: #d9534f;
    padding: 15px 30px;
    border-radius: 4px;
    display: inline-block;
    margin-bottom: 30px;
    font-weight: 600;
    font-size: 16px;
}

.btn-login {
    background-color: #7EADDB;
    color: white;
    border: none;
    padding: 15px 40px;
    font-size: 18px;
    font-weight: 600;
    border-radius: 4px;
    transition: all 0.3s;
}

.btn-login:hover {
    background-color: #6589AB;
    color: white;
    box-shadow: 0 4px 8px rgba(0,0,0,0.2);
    transform: translateY(-2px);
}

/* Features Section */
.features-section {
    background-color: white;
    padding: 60px 0;
}

.features-section h2 {
    text-align: center;
    font-size: 36px;
    font-weight: 300;
    margin-bottom: 50px;
    color: #333;
}

.feature-box {
    text-align: center;
    padding: 30px 20px;
    margin-bottom: 30px;
}

.feature-icon {
    font-size: 60px;
    margin-bottom: 20px;
    color: #7EADDB;
}

.feature-box h3 {
    font-size: 22px;
    font-weight: 600;
    margin-bottom: 15px;
    color: #333;
}

.feature-box p {
    font-size: 15px;
    color: #666;
    line-height: 1.6;
}
+16.2 KiB
Loading image diff...
Loading
Loading