Skip to content

Commit

Permalink
Merge pull request #1 from penguineer/login
Browse files Browse the repository at this point in the history
Implement OIDC login via GitHub
  • Loading branch information
penguineer authored Jul 17, 2024
2 parents cfacc3f + b82ed07 commit a2eda7e
Show file tree
Hide file tree
Showing 13 changed files with 407 additions and 7 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
> Specific Garten Management for our group

## Configuration

Configuration is done using environment variables:

* `PORT`: Port for the HTTP endpoint (default `8080`, only change when running locally!)
* `OAUTH_CALLBACK_BASE_URI`: Base URI for the OAuth callback (defaults to `http://localhost:8080`)
* `GITHUB_OAUTH_CLIENT_ID`: GitHub OAuth Client ID (defaults to none and will disable GitHub authentication if not set)
* `GITHUB_OAUTH_CLIENT_SECRET`: GitHub OAuth Client Secret (defaults to none and will disable GitHub authentication if not set)
* `MYSQL_HOST`: MySQL host (defaults to `localhost`)
* `MYSQL_PORT`: MySQL port (defaults to `3306`)
* `MYSQL_DB`: MySQL database (defaults to `gartenplus`)
* `MYSQL_USER`: MySQL user (defaults to `gartenplus`)
* `MYSQL_PASS`: MySQL password


## Maintainers

* Stefan Haun ([@penguineer](https://github.com/penguineer))
Expand Down
14 changes: 9 additions & 5 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
<vaadin.version>24.4.6</vaadin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
Expand All @@ -44,11 +48,6 @@
<artifactId>spring-session-core</artifactId>
</dependency>

<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
Expand All @@ -74,6 +73,11 @@
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.0.0</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package com.penguineering.gartenplus;

import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
public class GartenplusApplication {
@EnableJpaRepositories
@Push
@EnableAsync
public class GartenplusApplication implements AppShellConfigurator {

public static void main(String[] args) {
SpringApplication.run(GartenplusApplication.class, args);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.penguineering.gartenplus.auth;

import com.penguineering.gartenplus.auth.user.UserDTO;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.util.Collection;
import java.util.Map;

public class GartenplusUser implements OAuth2User {
private final OAuth2User origin;

@Getter
private final UserDTO user;

public GartenplusUser(OAuth2User origin, UserDTO user) {
this.origin = origin;
this.user = user;
}

@Override
public Map<String, Object> getAttributes() {
return origin.getAttributes();
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return origin.getAuthorities();
}

@Override
public String getName() {
return user.displayName();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.penguineering.gartenplus.auth;

import com.penguineering.gartenplus.auth.mapping.OidcMapping;
import com.penguineering.gartenplus.auth.mapping.OidcMappingKey;
import com.penguineering.gartenplus.auth.mapping.OidcMappingRepository;
import com.penguineering.gartenplus.auth.user.UserDTO;
import com.penguineering.gartenplus.auth.user.UserEntity;
import com.penguineering.gartenplus.auth.user.UserRepository;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.net.URI;
import java.util.Optional;

@Service
public class GithubOidcUserService extends DefaultOAuth2UserService {
private final OidcMappingRepository oidcMappingRepository;
private final UserRepository userRepository;

public GithubOidcUserService(
OidcMappingRepository oidcMappingRepository,
UserRepository userRepository) {
this.oidcMappingRepository = oidcMappingRepository;
this.userRepository = userRepository;
}

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
OAuth2User origin = super.loadUser(userRequest);

try {
// Custom logic to map GitHub user info to your application's user model
return findUserByOriginId(origin)
.map(user -> new GartenplusUser(origin, user))
.orElseGet(() -> new GartenplusUser(origin, createUser(origin)));
} catch (Exception e) {
throw new InternalAuthenticationServiceException("Problem during login", e);
}
}

private OidcMappingKey toMappingKey(OAuth2User origin) {
return Optional.ofNullable(origin.getAttribute("id"))
.map(String::valueOf)
.map(id -> OidcMappingKey.of("github", id))
.orElseThrow(() -> new IllegalArgumentException("id attribute is missing"));
}

private Optional<UserDTO> findUserByOriginId(OAuth2User origin) {
return Optional
.ofNullable(origin)
.map(this::toMappingKey)
.flatMap(oidcMappingRepository::findById)
.map(OidcMapping::getUserId)
.flatMap(userRepository::findById)
.map(UserEntity::toDTO);
}

private UserDTO createUser(OAuth2User origin) {
final String origin_id = Optional.ofNullable(origin.getAttribute("id"))
.map(Object::toString)
.orElseThrow(() -> new IllegalArgumentException("id attribute is missing"));
final String origin_name = origin.getAttribute("name");
final String origin_email = origin.getAttribute("email");
final String origin_avatar_url = origin.getAttribute("avatar_url");

UserEntity userEntity = new UserEntity();
userEntity.setDisplayName(origin_name);
userEntity.setEmail(origin_email);
Optional.ofNullable(origin_avatar_url)
.map(URI::create)
.ifPresent(userEntity::setAvatarUrl);
UserEntity savedUserEntity = userRepository.save(userEntity);

OidcMapping oidcMapping = new OidcMapping();
oidcMapping.setIssuer("github");
oidcMapping.setOidcId(origin_id);
oidcMapping.setUserId(savedUserEntity.getId());
oidcMappingRepository.save(oidcMapping);

return savedUserEntity.toDTO();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.penguineering.gartenplus.auth;

import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends VaadinWebSecurity {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.oauth2Login(login -> login
.defaultSuccessUrl("/"));

http.sessionManagement(session -> session
.sessionFixation().migrateSession());

http.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID"));

// Allow unauthenticated access to Health Endpoint and logo
http.authorizeHttpRequests(auth -> auth
.requestMatchers(AntPathRequestMatcher.antMatcher("/actuator/health")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/assets/GaretnPlus_Logo.png")).permitAll()
);

super.configure(http);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.penguineering.gartenplus.auth.mapping;

import jakarta.persistence.*;
import lombok.*;

import java.util.UUID;

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = {"issuer", "oidcId"})
@IdClass(OidcMappingKey.class)
@Table(name = "oidc_mapping")
public class OidcMapping {
@Id
@Column(name = "issuer")
private String issuer;

@Id
@Column(name = "oidc_id")
private String oidcId;

@Column(name = "user_id")
private UUID userId;

@Override
public String toString() {
return issuer + "/" + oidcId + " -> " + userId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.penguineering.gartenplus.auth.mapping;

import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.web.server.WebSession;

import java.io.Serializable;
import java.util.Optional;

@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode
public class OidcMappingKey implements Serializable {
protected static final String SESSION_KEY = "oidc-mapping-key";

private String issuer;
private String oidcId;

public static OidcMappingKey of(String issuer, String oidcId) {
return new OidcMappingKey(issuer, oidcId);
}

public static Optional<OidcMappingKey> ofWebSession(WebSession session) {
return Optional.ofNullable(session.getAttributes().get(SESSION_KEY))
.filter(key -> key instanceof OidcMappingKey)
.map(OidcMappingKey.class::cast);
}

public OidcMappingKey(String issuer, String oidcId) {
this.issuer = issuer;
this.oidcId = oidcId;
}

public Optional<OidcMappingKey> saveToWebSession(WebSession session) {
return Optional
.ofNullable(session.getAttributes().put(SESSION_KEY, this))
.filter(key -> key instanceof OidcMappingKey)
.map(OidcMappingKey.class::cast);
}

@Override
public String toString() {
return issuer + "/" + oidcId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.penguineering.gartenplus.auth.mapping;

import org.springframework.data.repository.CrudRepository;

public interface OidcMappingRepository extends CrudRepository<OidcMapping, OidcMappingKey> {
}
45 changes: 45 additions & 0 deletions src/main/java/com/penguineering/gartenplus/auth/user/UserDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.penguineering.gartenplus.auth.user;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.web.server.WebSession;

import java.net.URI;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record UserDTO(
@JsonProperty("id") UUID id,
@JsonProperty("display_name") String displayName,
@JsonProperty("email") String email,
@JsonProperty("avatar_url") URI avatarUrl) {

public UserDTO {
Objects.requireNonNull(displayName, "displayName must not be null");
}

@Override
public String toString() {
return displayName + "(" + id + ")";
}

@JsonIgnore
public boolean isNew() {
return Objects.isNull(id);
}

public static Optional<UserDTO> fromWebSession(WebSession session) {
return Optional.ofNullable(session.getAttribute("user"))
.filter(user -> user instanceof UserDTO)
.map(UserDTO.class::cast);
}

public Optional<UserDTO> saveToWebSession(WebSession session) {
return Optional.ofNullable(session.getAttributes().put("user", this))
.filter(user -> user instanceof UserDTO)
.map(UserDTO.class::cast);
}
}
Loading

0 comments on commit a2eda7e

Please sign in to comment.