Extended security for web front end and added first rest resources
This commit is contained in:
@@ -20,10 +20,12 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "customer")
|
||||
@NamedQuery(name = Customer.FIND_ALL, query = "select c from Customer c order by c.name")
|
||||
@NamedQuery(name = Customer.FIND_BY_NUMBER, query = "select c from Customer c where c.customerNumber = :cutomerNumber")
|
||||
public class Customer extends AbstractDateEntity {
|
||||
private static final long serialVersionUID = 1L;
|
||||
public static final String SEQUENCE = "customer_seq";
|
||||
public static final String FIND_ALL = "Customer.findAll";
|
||||
public static final String FIND_BY_NUMBER = "Customer.findByNumber";
|
||||
public static final String PARAM_NUMBER = "cutomerNumber";
|
||||
|
||||
@@ -39,8 +41,7 @@ public class Customer extends AbstractDateEntity {
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JoinColumn(name = "customer_id_fk")
|
||||
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private Set<Picture> pictures = new HashSet<>();
|
||||
|
||||
public Long getCustomerId() {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.core.model;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 21 Jan 2026
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "jwt_refresh_token")
|
||||
@NamedQuery(name = JwtRefreshToken.FIND_BY_HASH_REVOKE_NULL, query = "SELECT t FROM JwtRefreshToken t WHERE t.tokenHash = :hash AND t.revokedAt IS NULL")
|
||||
@NamedQuery(name = JwtRefreshToken.FIND_BY_HASH, query = "SELECT t FROM JwtRefreshToken t WHERE t.tokenHash = :hash")
|
||||
@NamedQuery(name = JwtRefreshToken.REVOKE_ALL_USER, query = "UPDATE JwtRefreshToken t SET t.revokedAt = :date WHERE t.user.userId = :userId AND t.revokedAt IS NULL")
|
||||
public class JwtRefreshToken extends AbstractEntity {
|
||||
private static final long serialVersionUID = 1L;
|
||||
public static final String SEQUENCE = "jwt_refresh_token_seq";
|
||||
public static final String FIND_BY_HASH_REVOKE_NULL = "JwtRefreshToken.forHashRevokeNull";
|
||||
public static final String FIND_BY_HASH = "JwtRefreshToken.forHash";
|
||||
public static final String REVOKE_ALL_USER = "JwtRefreshToken.revokeAllUser";
|
||||
public static final String PARAM_HASH = "hash";
|
||||
public static final String PARAM_USER_ID = "userId";
|
||||
public static final String PARAM_DATE = "date";
|
||||
|
||||
@Id
|
||||
@Column(name = "jwt_refresh_token_id", length = 22)
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = SEQUENCE)
|
||||
@SequenceGenerator(name = SEQUENCE, sequenceName = SEQUENCE, allocationSize = 1)
|
||||
private Long jwsRefreshTokenId;
|
||||
|
||||
@Column(name = "token_hash", nullable = false)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "device_info", nullable = false)
|
||||
private String deviceInfo;
|
||||
|
||||
@Column(name = "ip_address", nullable = false)
|
||||
private String ipAddress;
|
||||
|
||||
@Temporal(TemporalType.TIMESTAMP)
|
||||
@Column(name = "issued_at", nullable = false)
|
||||
@JsonIgnore
|
||||
private Date issuedAt;
|
||||
|
||||
@Temporal(TemporalType.TIMESTAMP)
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
@JsonIgnore
|
||||
private Date expiresAt;
|
||||
|
||||
@Temporal(TemporalType.TIMESTAMP)
|
||||
@Column(name = "revoked_at", nullable = false)
|
||||
@JsonIgnore
|
||||
private Date revokedAt;
|
||||
|
||||
@Temporal(TemporalType.TIMESTAMP)
|
||||
@Column(name = "last_used_at", nullable = false)
|
||||
@JsonIgnore
|
||||
private Date lastUsedAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "user_id_fk", nullable = true)
|
||||
private User user;
|
||||
|
||||
public Long getJwsRefreshTokenId() {
|
||||
return jwsRefreshTokenId;
|
||||
}
|
||||
|
||||
public void setJwsRefreshTokenId(Long jwsRefreshTokenId) {
|
||||
this.jwsRefreshTokenId = jwsRefreshTokenId;
|
||||
}
|
||||
|
||||
public String getTokenHash() {
|
||||
return tokenHash;
|
||||
}
|
||||
|
||||
public void setTokenHash(String tokenHash) {
|
||||
this.tokenHash = tokenHash;
|
||||
}
|
||||
|
||||
public String getDeviceInfo() {
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
public void setDeviceInfo(String deviceInfo) {
|
||||
this.deviceInfo = deviceInfo;
|
||||
}
|
||||
|
||||
public String getIpAddress() {
|
||||
return ipAddress;
|
||||
}
|
||||
|
||||
public void setIpAddress(String ipAddress) {
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
public Date getIssuedAt() {
|
||||
return issuedAt;
|
||||
}
|
||||
|
||||
public void setIssuedAt(Date issuedAt) {
|
||||
this.issuedAt = issuedAt;
|
||||
}
|
||||
|
||||
public Date getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Date expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public Date getRevokedAt() {
|
||||
return revokedAt;
|
||||
}
|
||||
|
||||
public void setRevokedAt(Date revokedAt) {
|
||||
this.revokedAt = revokedAt;
|
||||
}
|
||||
|
||||
public Date getLastUsedAt() {
|
||||
return lastUsedAt;
|
||||
}
|
||||
|
||||
public void setLastUsedAt(Date lastUsedAt) {
|
||||
this.lastUsedAt = lastUsedAt;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private JwtRefreshToken instance = new JwtRefreshToken();
|
||||
|
||||
public Builder tokenHash(String tokenHash) {
|
||||
instance.setTokenHash(tokenHash);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder deviceInfo(String deviceInfo) {
|
||||
instance.setDeviceInfo(deviceInfo);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder ipAddress(String ipAddress) {
|
||||
instance.setIpAddress(ipAddress);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder expiresAt(Date expiresAt) {
|
||||
instance.setExpiresAt(expiresAt);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder issuedAt(Date issuedAt) {
|
||||
instance.setIssuedAt(issuedAt);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder revokedAt(Date revokedAt) {
|
||||
instance.setRevokedAt(revokedAt);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder lastUsedAt(Date lastUsedAt) {
|
||||
instance.setLastUsedAt(lastUsedAt);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder user(User user) {
|
||||
instance.setUser(user);
|
||||
return this;
|
||||
}
|
||||
|
||||
public JwtRefreshToken build() {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,17 @@ public class Picture extends AbstractDateEntity {
|
||||
@Column(name = "picture_date", nullable = false)
|
||||
private Date pictureDate;
|
||||
|
||||
@Basic(fetch = FetchType.LAZY)
|
||||
private String comment;
|
||||
|
||||
@Column(name = "image")
|
||||
@Basic(fetch = FetchType.LAZY)
|
||||
private String image;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "customer_id_fk")
|
||||
private Customer customer;
|
||||
|
||||
public Long getPictureId() {
|
||||
return pictureId;
|
||||
}
|
||||
@@ -82,6 +88,14 @@ public class Picture extends AbstractDateEntity {
|
||||
this.image = image;
|
||||
}
|
||||
|
||||
public Customer getCustomer() {
|
||||
return customer;
|
||||
}
|
||||
|
||||
public void setCustomer(Customer customer) {
|
||||
this.customer = customer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return new HashCodeBuilder().append(pictureId).toHashCode();
|
||||
@@ -117,6 +131,11 @@ public class Picture extends AbstractDateEntity {
|
||||
instance.setImage(image);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder customer(Customer customer) {
|
||||
instance.setCustomer(customer);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Picture build() {
|
||||
return instance;
|
||||
|
||||
@@ -20,9 +20,12 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "x_user")
|
||||
@NamedQuery(name = User.BY_USERNAME, query = "select u from User u where u.username like :username")
|
||||
public class User extends AbstractDateEntity {
|
||||
private static final long serialVersionUID = 1L;
|
||||
public static final String SEQUENCE = "user_seq";
|
||||
public static final String BY_USERNAME = "user.byUsername";
|
||||
public static final String PARAM_USERNAME = "username";
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id", length = 22)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.core.service;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.ejb.EJB;
|
||||
import jakarta.ejb.EJBContext;
|
||||
import jakarta.ejb.SessionContext;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.query.QueryService;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 21 Jan 2026
|
||||
*/
|
||||
|
||||
public abstract class AbstractService {
|
||||
|
||||
@Resource
|
||||
protected EJBContext ejbContext;
|
||||
|
||||
@PersistenceContext
|
||||
protected EntityManager entityManager;
|
||||
|
||||
@Resource
|
||||
protected SessionContext sessionContext;
|
||||
|
||||
@EJB
|
||||
protected QueryService queryService;
|
||||
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.core.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import jakarta.ejb.EJB;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.ejb.LocalBean;
|
||||
import jakarta.ejb.Stateless;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.query.Param;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.query.QueryService;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerPictureValue;
|
||||
|
||||
/**
|
||||
@@ -24,25 +23,26 @@ import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerPictureValue;
|
||||
*/
|
||||
@Stateless
|
||||
@LocalBean
|
||||
public class CustomerPictureService {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
@EJB
|
||||
private QueryService queryService;
|
||||
@PermitAll
|
||||
public class CustomerPictureService extends AbstractService {
|
||||
|
||||
public boolean addCustomerPicture(CustomerPictureValue customerPictureValue) {
|
||||
Optional<Customer> customerOpt = queryService.callNamedQuerySingleResult(Customer.FIND_BY_NUMBER, new Param(Customer.PARAM_NUMBER, customerPictureValue.customerNumber()));
|
||||
Customer customer = customerOpt.orElseGet(() -> new Customer.Builder().customerNumber(customerPictureValue.customerNumber()).name(customerPictureValue.pharmacyName()).build());
|
||||
|
||||
Picture picture = new Picture.Builder().username(customerPictureValue.username()).comment(customerPictureValue.comment()).image(customerPictureValue.base64String()).pictureDate(customerPictureValue.date()).build();
|
||||
customer = entityManager.merge(customer);
|
||||
|
||||
Picture picture = new Picture.Builder().customer(customer).username(customerPictureValue.username()).comment(customerPictureValue.comment()).image(customerPictureValue.base64String()).pictureDate(customerPictureValue.date()).build();
|
||||
customer.getPictures().add(picture);
|
||||
|
||||
entityManager.persist(picture);
|
||||
entityManager.merge(customer);
|
||||
entityManager.flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<CustomerListValue> getAll(String query) {
|
||||
// FIXME: do query
|
||||
List<Customer> customers = queryService.callNamedQueryList(Customer.FIND_ALL);
|
||||
customers.forEach(c -> c.getPictures().size());
|
||||
return customers.parallelStream().map(c -> CustomerListValue.builder(c)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.core.service;
|
||||
|
||||
import static marketing.heyday.hartmann.fotodocumentation.core.model.JwtRefreshToken.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Optional;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.inject.Inject;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.JwtRefreshToken;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Right;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.User;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.query.Param;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.utils.JwtTokenUtil;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.TokenPairValue;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 21 Jan 2026
|
||||
*/
|
||||
|
||||
public class JwtTokenService extends AbstractService {
|
||||
private static final Long EXPIRED_DELTA = 30L * 24 * 60 * 60 * 1000;
|
||||
|
||||
@Inject
|
||||
private JwtTokenUtil jwtTokenUtil;
|
||||
|
||||
public TokenPairValue generateTokenPair(User user, String deviceInfo, String ipAddress) {
|
||||
String accessToken = jwtTokenUtil.generateAccessToken(
|
||||
user.getUserId(),
|
||||
user.getUsername(),
|
||||
user.getRights().stream().map(Right::getCode).toList());
|
||||
|
||||
String refreshToken = jwtTokenUtil.generateRefreshToken(user.getUserId());
|
||||
|
||||
// Store refresh token (optional - for revocation support)
|
||||
var refreshTokenEntity = new JwtRefreshToken.Builder()
|
||||
.user(user)
|
||||
.tokenHash(hashToken(refreshToken))
|
||||
.deviceInfo(deviceInfo)
|
||||
.ipAddress(ipAddress)
|
||||
.issuedAt(new Date())
|
||||
.expiresAt(new Date(System.currentTimeMillis() + EXPIRED_DELTA)).build();
|
||||
entityManager.persist(refreshTokenEntity);
|
||||
|
||||
return new TokenPairValue(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
public String refreshAccessToken(String refreshToken) throws Exception {
|
||||
Claims claims = jwtTokenUtil.validateAndExtractClaims(refreshToken);
|
||||
Long userId = Long.parseLong(claims.getSubject());
|
||||
|
||||
// Verify refresh token exists and not revoked
|
||||
String tokenHash = hashToken(refreshToken);
|
||||
|
||||
Optional<JwtRefreshToken> tokenEntityOpt = queryService.callNamedQuerySingleResult(FIND_BY_HASH_REVOKE_NULL, new Param(PARAM_HASH, tokenHash));
|
||||
if (tokenEntityOpt.isEmpty()) {
|
||||
// FIXME: do error handling
|
||||
}
|
||||
|
||||
var tokenEntity = tokenEntityOpt.get();
|
||||
tokenEntity.setLastUsedAt(new Date());
|
||||
entityManager.merge(tokenEntity);
|
||||
|
||||
User user = entityManager.find(User.class, userId);
|
||||
return jwtTokenUtil.generateAccessToken(user.getUserId(), user.getUsername(), user.getRights().stream().map(Right::getCode).toList());
|
||||
}
|
||||
|
||||
public void revokeRefreshToken(String refreshToken) {
|
||||
String tokenHash = hashToken(refreshToken);
|
||||
Optional<JwtRefreshToken> tokenEntityOpt = queryService.callNamedQuerySingleResult(FIND_BY_HASH, new Param(PARAM_HASH, tokenHash));
|
||||
if (tokenEntityOpt.isPresent()) {
|
||||
JwtRefreshToken tokenEntity = tokenEntityOpt.get();
|
||||
tokenEntity.setRevokedAt(new Date());
|
||||
entityManager.merge(tokenEntity);
|
||||
}
|
||||
}
|
||||
|
||||
public void revokeAllUserTokens(Long userId) {
|
||||
queryService.callNamedQueryUpdate(REVOKE_ALL_USER, new Param(PARAM_DATE, new Date()), new Param(PARAM_USER_ID, userId));
|
||||
}
|
||||
|
||||
private String hashToken(String token) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.core.service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import jakarta.ejb.LocalBean;
|
||||
import jakarta.ejb.Stateless;
|
||||
import jakarta.inject.Inject;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.User;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.query.Param;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.TokenPairValue;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 21 Jan 2026
|
||||
*/
|
||||
@Stateless
|
||||
@LocalBean
|
||||
public class LoginService extends AbstractService{
|
||||
|
||||
@Inject
|
||||
private JwtTokenService jwtTokenService;
|
||||
|
||||
public TokenPairValue authenticateUser(String username, String deviceInfo, String ipAddress) {
|
||||
// Get logged-in user from database
|
||||
Optional<User> userOpt = queryService.callNamedQuerySingleResult(User.BY_USERNAME, new Param(User.PARAM_USERNAME, username));
|
||||
if (userOpt.isEmpty()) {
|
||||
// FIXME: implement me
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
// Verify user is active
|
||||
if (!user.isActive()) {
|
||||
throw new IllegalArgumentException("User account is inactive");
|
||||
}
|
||||
|
||||
TokenPairValue tokens = jwtTokenService.generateTokenPair(user, deviceInfo, ipAddress);
|
||||
// Logout from the temporary login (we're using tokens now, not session)
|
||||
return tokens;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.core.utils;
|
||||
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.ejb.LocalBean;
|
||||
import jakarta.ejb.Stateless;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 21 Jan 2026
|
||||
*/
|
||||
@Stateless
|
||||
@LocalBean
|
||||
public class JwtTokenUtil {
|
||||
private static final Log LOG = LogFactory.getLog(JwtTokenUtil.class);
|
||||
|
||||
private static final long ACCESS_TOKEN_VALIDITY = 60 * 60 * 1000L; // 1 hour
|
||||
private static final long REFRESH_TOKEN_VALIDITY = 30 * 24 * 60 * 60 * 1000L; // 30 days
|
||||
private static final long TEMP_2FA_TOKEN_VALIDITY = 5 * 60 * 1000L; // 5 minutes
|
||||
|
||||
private static final String ISSUER = "skillmatrix-jwt-issuer";
|
||||
private static final String AUDIENCE = "skillmatrix-api";
|
||||
|
||||
private PrivateKey privateKey;
|
||||
private PublicKey publicKey;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// Load key from wildfly system property
|
||||
try {
|
||||
String pem = System.getProperty("jwt.secret.key");
|
||||
pem = pem.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] decoded = Base64.getDecoder().decode(pem);
|
||||
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
|
||||
privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
} catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
|
||||
LOG.error("Failed to load JWT PrivateKey " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateAccessToken(Long userId, String username, List<String> groups) {
|
||||
return Jwts.builder()
|
||||
.issuer(ISSUER)
|
||||
.audience().add(AUDIENCE).and()
|
||||
.subject(userId.toString())
|
||||
.claim("username", username)
|
||||
.claim("type", "access")
|
||||
.claim("groups", groups)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
|
||||
.signWith(privateKey, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateRefreshToken(Long userId) {
|
||||
return Jwts.builder()
|
||||
.issuer(ISSUER)
|
||||
.audience().add(AUDIENCE).and()
|
||||
.subject(userId.toString())
|
||||
.claim("type", "refresh")
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_VALIDITY))
|
||||
.signWith(privateKey, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateTemp2FAToken(Long userId) {
|
||||
return Jwts.builder()
|
||||
.issuer(ISSUER)
|
||||
.audience().add(AUDIENCE).and()
|
||||
.subject(userId.toString())
|
||||
.claim("type", "temp_2fa")
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + TEMP_2FA_TOKEN_VALIDITY))
|
||||
.signWith(privateKey, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims validateAndExtractClaims(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey) // FIXME: not working need public key that we currently didn't load
|
||||
.build()
|
||||
.parseUnsecuredClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public Long extractUserId(String token) {
|
||||
Claims claims = validateAndExtractClaims(token);
|
||||
return Long.parseLong(claims.getSubject());
|
||||
}
|
||||
|
||||
public boolean isTokenExpired(String token) {
|
||||
try {
|
||||
Claims claims = validateAndExtractClaims(token);
|
||||
return claims.getExpiration().before(new Date());
|
||||
} catch (JwtException e) {
|
||||
LOG.warn("Failed to get expiration date from token " + e.getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,14 @@ public class LoginUtils {
|
||||
|
||||
private Optional<SecurityIdentity> authenticate(String username, String password) {
|
||||
try {
|
||||
LOG.error("Login with username: " + username + " password: " + password);
|
||||
Principal principal = new NamePrincipal(username);
|
||||
PasswordGuessEvidence evidence = new PasswordGuessEvidence(password.toCharArray());
|
||||
|
||||
SecurityDomain sd = SecurityDomain.getCurrent();
|
||||
return Optional.ofNullable(sd.authenticate(principal, evidence));
|
||||
SecurityIdentity identity = sd.authenticate(principal, evidence);
|
||||
LOG.error("Login identity: " + identity);
|
||||
return Optional.ofNullable(identity);
|
||||
} catch (RealmUnavailableException | SecurityException e) {
|
||||
LOG.warn("Failed to authenticate user " + e.getMessage(), e);
|
||||
return Optional.empty();
|
||||
|
||||
@@ -58,11 +58,7 @@ public class CustomerPictureResource {
|
||||
return Response.status(Status.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
|
||||
boolean success = customerPictureService.addCustomerPicture(customerPictureValue);
|
||||
|
||||
return success ? Response.ok().build() : Response.status(Status.BAD_REQUEST).build();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.rest;
|
||||
|
||||
import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.jboss.resteasy.annotations.GZIP;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.ejb.EJB;
|
||||
import jakarta.enterprise.context.RequestScoped;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.service.CustomerPictureService;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 21 Jan 2026
|
||||
*/
|
||||
@RequestScoped
|
||||
@Path("customer")
|
||||
public class CustomerResource {
|
||||
private static final Log LOG = LogFactory.getLog(CustomerResource.class);
|
||||
|
||||
@EJB
|
||||
private CustomerPictureService customerPictureService;
|
||||
|
||||
@GZIP
|
||||
@GET
|
||||
@Path("")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Get customer list")
|
||||
@ApiResponse(responseCode = "200", description = "Successfully retrieved customer list", content = @Content(mediaType = JSON_OUT, array = @ArraySchema(schema = @Schema(implementation = CustomerListValue.class))))
|
||||
public Response doAddCustomerPicture(@QueryParam("query") String query) {
|
||||
LOG.debug("Query customers for query " + query);
|
||||
var retVal = customerPictureService.getAll(query);
|
||||
return Response.ok().entity(retVal).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.rest;
|
||||
|
||||
import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.wildfly.security.auth.server.SecurityIdentity;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.ejb.EJB;
|
||||
import jakarta.enterprise.context.RequestScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.service.LoginService;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.utils.LoginUtils;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.TokenPairValue;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 21 Jan 2026
|
||||
*/
|
||||
|
||||
@RequestScoped
|
||||
@Path("login")
|
||||
@PermitAll
|
||||
public class LoginResource {
|
||||
private static final Log LOG = LogFactory.getLog(LoginResource.class);
|
||||
|
||||
@EJB
|
||||
private LoginService loginService;
|
||||
|
||||
@Inject
|
||||
private LoginUtils loginUtils;
|
||||
|
||||
@GET
|
||||
@Path("/")
|
||||
@Produces(JSON_OUT)
|
||||
@Operation(summary = "Get logged in user")
|
||||
@ApiResponse(responseCode = "200", description = "Successfully retrieved logged in user", content = @Content(mediaType = JSON_OUT, schema = @Schema(implementation = TokenPairValue.class)))
|
||||
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||
public Response doLogin(@Context HttpServletRequest httpServletRequest) {
|
||||
Optional<SecurityIdentity> identity = loginUtils.authenticate(httpServletRequest);
|
||||
if (identity.isEmpty()) {
|
||||
LOG.debug("identity empty login invalid");
|
||||
return Response.status(401).build();
|
||||
}
|
||||
String username = identity.get().getPrincipal().getName();
|
||||
|
||||
LOG.debug("Login valid returning jwt");
|
||||
String deviceInfo = loginUtils.extractDeviceInfo(httpServletRequest);
|
||||
String ipAddress = loginUtils.extractIpAddress(httpServletRequest);
|
||||
|
||||
TokenPairValue tokenPairValue = loginService.authenticateUser(username, deviceInfo, ipAddress);
|
||||
|
||||
//FIXME: check if we can do a logout to free user from WildFly httpServletRequest.logout();
|
||||
return Response.ok(tokenPairValue).build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import jakarta.ws.rs.ApplicationPath;
|
||||
import jakarta.ws.rs.core.Application;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.CustomerPictureResource;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.CustomerResource;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.LoginResource;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.MonitoringResource;
|
||||
|
||||
/**
|
||||
@@ -42,9 +44,10 @@ public class ApplicationConfigApi extends Application {
|
||||
Set<Class<?>> retVal = new HashSet<>();
|
||||
retVal.add(OpenApiResource.class);
|
||||
retVal.add(ValidatedMessageBodyReader.class);
|
||||
//retVal.add(AuthenticateFilter.class);
|
||||
retVal.add(LoginResource.class);
|
||||
retVal.add(MonitoringResource.class);
|
||||
retVal.add(CustomerPictureResource.class);
|
||||
retVal.add(CustomerResource.class);
|
||||
LOG.info("returning rest api classes " + retVal);
|
||||
return retVal;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
|
||||
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 19 Jan 2026
|
||||
*/
|
||||
|
||||
public record CustomerListValue(String name, String customerNumber, int amountOfPicture) {
|
||||
|
||||
public static CustomerListValue builder(Customer customer) {
|
||||
if (customer == null) {
|
||||
return null;
|
||||
}
|
||||
return new CustomerListValue(customer.getName(), customer.getCustomerNumber(), customer.getPictures().size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
*
|
||||
* <p>Copyright: Copyright (c) 2024</p>
|
||||
* <p>Company: heyday Marketing GmbH</p>
|
||||
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||
* @version 1.0
|
||||
*
|
||||
* created: 21 Jan 2026
|
||||
*/
|
||||
|
||||
@Schema(name = "TokenPair")
|
||||
public record TokenPairValue(String accessToken, String refreshToken) {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TokenPair{" + "accessToken='" + (accessToken != null ? "[REDACTED]" : "null") + '\'' + ", refreshToken='" + (refreshToken != null ? "[REDACTED]" : "null") + '\'' + '}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<class>marketing.heyday.hartmann.fotodocumentation.core.model.User</class>
|
||||
<class>marketing.heyday.hartmann.fotodocumentation.core.model.Customer</class>
|
||||
<class>marketing.heyday.hartmann.fotodocumentation.core.model.Picture</class>
|
||||
<class>marketing.heyday.hartmann.fotodocumentation.core.model.JwtRefreshToken</class>
|
||||
|
||||
<properties>
|
||||
<property name="hibernate.format_sql" value="false" />
|
||||
|
||||
@@ -42,6 +42,28 @@ create table user_to_right (
|
||||
);
|
||||
|
||||
|
||||
-- jwt_refresh_token
|
||||
|
||||
CREATE SEQUENCE jwt_refresh_token_seq START 1 INCREMENT 1;
|
||||
|
||||
CREATE TABLE jwt_refresh_token (
|
||||
jwt_refresh_token_id BIGINT PRIMARY KEY,
|
||||
token_hash VARCHAR(255) NOT NULL, -- SHA-256 hash of refresh token
|
||||
device_info VARCHAR(255), -- Browser/device identifier
|
||||
ip_address VARCHAR(45),
|
||||
issued_at TIMESTAMP NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
revoked_at TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
user_id_fk BIGINT NOT NULL REFERENCES x_user(user_id),
|
||||
UNIQUE (token_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_jwt_refresh_token_user ON jwt_refresh_token(user_id_fk);
|
||||
CREATE INDEX idx_jwt_refresh_token_expires ON jwt_refresh_token(expires_at);
|
||||
|
||||
|
||||
|
||||
-- customer
|
||||
|
||||
create sequence customer_seq start 25;
|
||||
|
||||
@@ -39,11 +39,6 @@ public class SecurityGenerator {
|
||||
|
||||
}
|
||||
|
||||
public byte[] createPassword(String password, String salt) throws NoSuchAlgorithmException {
|
||||
byte[] saltBytes = salt.getBytes(Charset.forName("utf-8"));
|
||||
return createPassword(password, saltBytes);
|
||||
}
|
||||
|
||||
public byte[] createPassword(String password, byte[] salt) throws NoSuchAlgorithmException {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] passwordBytes = password.getBytes(Charset.forName("utf-8"));
|
||||
@@ -66,8 +61,8 @@ public class SecurityGenerator {
|
||||
byte[] salt = createSalt();
|
||||
String saltHash = encode(salt);
|
||||
|
||||
byte[] passwordByte = createPassword(password, salt);
|
||||
String passwordHash = encode(passwordByte);
|
||||
byte[] digest = createPassword(password, salt);
|
||||
String passwordHash = encode(digest);
|
||||
|
||||
System.out.println("Password " + password);
|
||||
System.out.println("PasswordHash " + passwordHash);
|
||||
|
||||
Reference in New Issue
Block a user