diff --git a/libresonic-main/pom.xml b/libresonic-main/pom.xml
index 58ca10ee..93a3f594 100644
--- a/libresonic-main/pom.xml
+++ b/libresonic-main/pom.xml
@@ -68,6 +68,11 @@
org.springframework.security
spring-security-ldap
+
+ com.auth0
+ java-jwt
+ 3.1.0
+
org.springframework.ldap
spring-ldap-core
@@ -115,7 +120,7 @@
commons-codec
commons-codec
- 1.2
+ 1.10
diff --git a/libresonic-main/src/main/java/org/libresonic/player/controller/ControllerUtils.java b/libresonic-main/src/main/java/org/libresonic/player/controller/ControllerUtils.java
index 7177a09e..d432ab1f 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/controller/ControllerUtils.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/controller/ControllerUtils.java
@@ -1,9 +1,25 @@
package org.libresonic.player.controller;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.servlet.HandlerMapping;
+
+import javax.servlet.http.HttpServletRequest;
+
/**
* This class has been created to refactor code previously present
* in the MultiController.
*/
public class ControllerUtils {
+ public static String extractMatched(final HttpServletRequest request){
+
+ String path = (String) request.getAttribute(
+ HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
+ String bestMatchPattern = (String ) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
+
+ AntPathMatcher apm = new AntPathMatcher();
+
+ return apm.extractPathWithinPattern(bestMatchPattern, path);
+
+ }
}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/controller/CoverArtController.java b/libresonic-main/src/main/java/org/libresonic/player/controller/CoverArtController.java
index da75d00a..ac7e26e5 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/controller/CoverArtController.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/controller/CoverArtController.java
@@ -57,7 +57,7 @@ import java.util.concurrent.Semaphore;
* @author Sindre Mehus
*/
@Controller
-@RequestMapping("/coverArt")
+@RequestMapping(value = {"/coverArt", "/ext/coverArt"})
public class CoverArtController implements LastModified {
public static final String ALBUM_COVERART_PREFIX = "al-";
diff --git a/libresonic-main/src/main/java/org/libresonic/player/controller/ExternalPlayerController.java b/libresonic-main/src/main/java/org/libresonic/player/controller/ExternalPlayerController.java
index dfcfc109..a69b599d 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/controller/ExternalPlayerController.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/controller/ExternalPlayerController.java
@@ -19,30 +19,28 @@
*/
package org.libresonic.player.controller;
+import com.auth0.jwt.interfaces.DecodedJWT;
import org.apache.commons.lang3.StringUtils;
-import org.libresonic.player.Logger;
-import org.libresonic.player.domain.MediaFile;
-import org.libresonic.player.domain.MusicFolder;
-import org.libresonic.player.domain.Player;
-import org.libresonic.player.domain.Share;
-import org.libresonic.player.service.MediaFileService;
-import org.libresonic.player.service.PlayerService;
-import org.libresonic.player.service.SettingsService;
-import org.libresonic.player.service.ShareService;
+import org.libresonic.player.domain.*;
+import org.libresonic.player.security.JWTAuthenticationToken;
+import org.libresonic.player.service.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
-import org.springframework.web.util.UrlPathHelper;
+import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
-
-import static org.libresonic.player.controller.ExternalPlayerController.SHARE_PATH;
+import java.util.stream.Collectors;
/**
* Controller for the page used to play shared music (Twitter, Facebook etc).
@@ -50,12 +48,10 @@ import static org.libresonic.player.controller.ExternalPlayerController.SHARE_PA
* @author Sindre Mehus
*/
@Controller
-@RequestMapping(SHARE_PATH + "**")
+@RequestMapping(value = {"/ext/share/**"})
public class ExternalPlayerController {
- private static final Logger LOG = Logger.getLogger(ExternalPlayerController.class);
-
- public static final String SHARE_PATH = "/share/";
+ private static final Logger LOG = LoggerFactory.getLogger(ExternalPlayerController.class);
@Autowired
private SettingsService settingsService;
@@ -65,15 +61,16 @@ public class ExternalPlayerController {
private ShareService shareService;
@Autowired
private MediaFileService mediaFileService;
+ @Autowired
+ private JWTSecurityService jwtSecurityService;
@RequestMapping(method = RequestMethod.GET)
protected ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response) throws Exception {
Map map = new HashMap<>();
- String pathRelativeToBase = new UrlPathHelper().getPathWithinApplication(request);
-
- String shareName = StringUtils.removeStart(pathRelativeToBase, SHARE_PATH);
+ String shareName = ControllerUtils.extractMatched(request);
+ LOG.debug("Share name is {}", shareName);
if(StringUtils.isBlank(shareName)) {
LOG.warn("Could not find share with shareName " + shareName);
@@ -97,24 +94,32 @@ public class ExternalPlayerController {
Player player = playerService.getGuestPlayer(request);
map.put("share", share);
- map.put("songs", getSongs(share, player.getUsername()));
- map.put("player", player.getId());
+ map.put("songs", getSongs(request, share, player));
return new ModelAndView("externalPlayer", "model", map);
}
- private List getSongs(Share share, String username) throws IOException {
- List result = new ArrayList();
+ private List getSongs(HttpServletRequest request, Share share, Player player) throws IOException {
+ Date expires = null;
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if(authentication instanceof JWTAuthenticationToken) {
+ DecodedJWT token = jwtSecurityService.verify((String) authentication.getCredentials());
+ expires = token.getExpiresAt();
+ }
+ Date finalExpires = expires;
+
+ List result = new ArrayList<>();
- List musicFolders = settingsService.getMusicFoldersForUser(username);
+ List musicFolders = settingsService.getMusicFoldersForUser(player.getUsername());
if (share != null) {
for (MediaFile file : shareService.getSharedFiles(share.getId(), musicFolders)) {
if (file.getFile().exists()) {
if (file.isDirectory()) {
- result.addAll(mediaFileService.getChildrenOf(file, true, false, true));
+ List childrenOf = mediaFileService.getChildrenOf(file, true, false, true);
+ result.addAll(childrenOf.stream().map(mf -> addUrlInfo(request, player, mf, finalExpires)).collect(Collectors.toList()));
} else {
- result.add(file);
+ result.add(addUrlInfo(request, player, file, finalExpires));
}
}
}
@@ -122,4 +127,26 @@ public class ExternalPlayerController {
return result;
}
+ public MediaFileWithUrlInfo addUrlInfo(HttpServletRequest request, Player player, MediaFile mediaFile, Date expires) {
+ String prefix = "/ext";
+ String streamUrl = jwtSecurityService.addJWTToken(
+ UriComponentsBuilder
+ .fromHttpUrl(NetworkService.getBaseUrl(request) + prefix + "/stream")
+ .queryParam("id", mediaFile.getId())
+ .queryParam("player", player.getId())
+ .queryParam("maxBitRate", "1200"),
+ expires)
+ .build()
+ .toUriString();
+
+ String coverArtUrl = jwtSecurityService.addJWTToken(
+ UriComponentsBuilder
+ .fromHttpUrl(NetworkService.getBaseUrl(request) + prefix + "/coverArt.view")
+ .queryParam("id", mediaFile.getId())
+ .queryParam("size", "500"),
+ expires)
+ .build()
+ .toUriString();
+ return new MediaFileWithUrlInfo(mediaFile, coverArtUrl, streamUrl);
+ }
}
\ No newline at end of file
diff --git a/libresonic-main/src/main/java/org/libresonic/player/controller/HLSController.java b/libresonic-main/src/main/java/org/libresonic/player/controller/HLSController.java
index a3ce786e..48083506 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/controller/HLSController.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/controller/HLSController.java
@@ -22,6 +22,7 @@ package org.libresonic.player.controller;
import org.apache.commons.lang.StringUtils;
import org.libresonic.player.domain.MediaFile;
import org.libresonic.player.domain.Player;
+import org.libresonic.player.service.JWTSecurityService;
import org.libresonic.player.service.MediaFileService;
import org.libresonic.player.service.PlayerService;
import org.libresonic.player.service.SecurityService;
@@ -33,6 +34,7 @@ import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
+import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -50,7 +52,7 @@ import java.util.regex.Pattern;
* @author Sindre Mehus
*/
@Controller(value = "hlsController")
-@RequestMapping("/hls/**")
+@RequestMapping(value = {"/hls/**", "/ext/hls/**"})
public class HLSController {
private static final int SEGMENT_DURATION = 10;
@@ -62,6 +64,8 @@ public class HLSController {
private MediaFileService mediaFileService;
@Autowired
private SecurityService securityService;
+ @Autowired
+ private JWTSecurityService jwtSecurityService;
@RequestMapping(method = RequestMethod.GET)
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
@@ -142,7 +146,12 @@ public class HLSController {
for (Pair bitRate : bitRates) {
Integer kbps = bitRate.getFirst();
writer.println("#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=" + kbps * 1000L);
- writer.print(contextPath + "hls/hls.m3u8?id=" + id + "&player=" + player.getId() + "&bitRate=" + kbps);
+ UriComponentsBuilder url = (UriComponentsBuilder.fromUriString(contextPath + "ext/hls/hls.m3u8")
+ .queryParam("id", id)
+ .queryParam("player", player.getId())
+ .queryParam("bitRate", kbps));
+ jwtSecurityService.addJWTToken(url);
+ writer.print(url.toUriString());
Dimension dimension = bitRate.getSecond();
if (dimension != null) {
writer.print("@" + dimension.width + "x" + dimension.height);
@@ -173,17 +182,22 @@ public class HLSController {
}
private String createStreamUrl(HttpServletRequest request, Player player, int id, int offset, int duration, Pair bitRate) {
- StringBuilder builder = new StringBuilder();
- builder.append(getContextPath(request)).append("stream/stream.ts?id=").append(id).append("&hls=true&timeOffset=").append(offset)
- .append("&player=").append(player.getId()).append("&duration=").append(duration);
+ UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(getContextPath(request) + "ext/stream/stream.ts");
+ builder.queryParam("id", id);
+ builder.queryParam("hls", "true");
+ builder.queryParam("timeOffset", offset);
+ builder.queryParam("player", player.getId());
+ builder.queryParam("duration", duration);
if (bitRate != null) {
- builder.append("&maxBitRate=").append(bitRate.getFirst());
+ builder.queryParam("maxBitRate", bitRate.getFirst());
Dimension dimension = bitRate.getSecond();
if (dimension != null) {
- builder.append("&size=").append(dimension.width).append("x").append(dimension.height);
+ builder.queryParam("size", dimension.width);
+ builder.queryParam("x", dimension.height);
}
}
- return builder.toString();
+ jwtSecurityService.addJWTToken(builder);
+ return builder.toUriString();
}
private String getContextPath(HttpServletRequest request) {
diff --git a/libresonic-main/src/main/java/org/libresonic/player/controller/ShareSettingsController.java b/libresonic-main/src/main/java/org/libresonic/player/controller/ShareSettingsController.java
index c92fc937..365b9d46 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/controller/ShareSettingsController.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/controller/ShareSettingsController.java
@@ -63,7 +63,6 @@ public class ShareSettingsController {
public String doGet(HttpServletRequest request, Model model) throws Exception {
Map map = new HashMap();
- map.put("shareBaseUrl", shareService.getShareBaseUrl(request));
map.put("shareInfos", getShareInfos(request));
map.put("user", securityService.getCurrentUser(request));
@@ -121,7 +120,9 @@ public class ShareSettingsController {
List files = shareService.getSharedFiles(share.getId(), musicFolders);
if (!files.isEmpty()) {
MediaFile file = files.get(0);
- result.add(new ShareInfo(share, file.isDirectory() ? file : mediaFileService.getParentOf(file)));
+ result.add(new ShareInfo(shareService.getShareUrl(request, share), share, file.isDirectory() ? file :
+ mediaFileService
+ .getParentOf(file)));
}
}
return result;
@@ -144,10 +145,12 @@ public class ShareSettingsController {
}
public static class ShareInfo {
+ private final String shareUrl;
private final Share share;
private final MediaFile dir;
- public ShareInfo(Share share, MediaFile dir) {
+ public ShareInfo(String shareUrl, Share share, MediaFile dir) {
+ this.shareUrl = shareUrl;
this.share = share;
this.dir = dir;
}
@@ -156,6 +159,10 @@ public class ShareSettingsController {
return share;
}
+ public String getShareUrl() {
+ return shareUrl;
+ }
+
public MediaFile getDir() {
return dir;
}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/controller/StreamController.java b/libresonic-main/src/main/java/org/libresonic/player/controller/StreamController.java
index c429000d..8bd50e5f 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/controller/StreamController.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/controller/StreamController.java
@@ -25,12 +25,15 @@ import org.libresonic.player.domain.*;
import org.libresonic.player.io.PlayQueueInputStream;
import org.libresonic.player.io.RangeOutputStream;
import org.libresonic.player.io.ShoutCastOutputStream;
+import org.libresonic.player.security.JWTAuthenticationToken;
import org.libresonic.player.service.*;
import org.libresonic.player.service.sonos.SonosHelper;
import org.libresonic.player.util.HttpRange;
import org.libresonic.player.util.StringUtil;
import org.libresonic.player.util.Util;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
@@ -55,7 +58,7 @@ import java.util.regex.Pattern;
* @author Sindre Mehus
*/
@Controller
-@RequestMapping("/stream/**")
+@RequestMapping(value = {"/stream/**", "/ext/stream/**"})
public class StreamController {
private static final Logger LOG = Logger.getLogger(StreamController.class);
@@ -86,10 +89,11 @@ public class StreamController {
PlayQueueInputStream in = null;
Player player = playerService.getPlayer(request, response, false, true);
User user = securityService.getUserByName(player.getUsername());
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
try {
- if (!user.isStreamRole()) {
+ if (!(authentication instanceof JWTAuthenticationToken) && !user.isStreamRole()) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Streaming is forbidden for user " + user.getUsername());
return null;
}
@@ -128,7 +132,7 @@ public class StreamController {
if (isSingleFile) {
- if (!securityService.isFolderAccessAllowed(file, user.getUsername())) {
+ if (!(authentication instanceof JWTAuthenticationToken) && !securityService.isFolderAccessAllowed(file, user.getUsername())) {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
"Access to file " + file.getId() + " is forbidden for user " + user.getUsername());
return null;
diff --git a/libresonic-main/src/main/java/org/libresonic/player/domain/MediaFileWithUrlInfo.java b/libresonic-main/src/main/java/org/libresonic/player/domain/MediaFileWithUrlInfo.java
new file mode 100644
index 00000000..356514f3
--- /dev/null
+++ b/libresonic-main/src/main/java/org/libresonic/player/domain/MediaFileWithUrlInfo.java
@@ -0,0 +1,324 @@
+package org.libresonic.player.domain;
+
+import com.google.common.base.Function;
+
+import java.io.File;
+import java.util.Date;
+import java.util.List;
+
+public class MediaFileWithUrlInfo {
+
+ private final MediaFile file;
+ private final String coverArtUrl;
+ private final String streamUrl;
+
+ public MediaFileWithUrlInfo(MediaFile file, String coverArtUrl, String streamUrl) {
+ this.file = file;
+ this.coverArtUrl = coverArtUrl;
+ this.streamUrl = streamUrl;
+ }
+
+ public String getStreamUrl() {
+ return streamUrl;
+ }
+
+ public String getCoverArtUrl() {
+ return coverArtUrl;
+ }
+
+ public int getId() {
+ return file.getId();
+ }
+
+ public void setId(int id) {
+ file.setId(id);
+ }
+
+ public String getPath() {
+ return file.getPath();
+ }
+
+ public void setPath(String path) {
+ file.setPath(path);
+ }
+
+ public String getFolder() {
+ return file.getFolder();
+ }
+
+ public void setFolder(String folder) {
+ file.setFolder(folder);
+ }
+
+ public File getFile() {
+ return file.getFile();
+ }
+
+ public boolean exists() {
+ return file.exists();
+ }
+
+ public MediaFile.MediaType getMediaType() {
+ return file.getMediaType();
+ }
+
+ public void setMediaType(MediaFile.MediaType mediaType) {
+ file.setMediaType(mediaType);
+ }
+
+ public boolean isVideo() {
+ return file.isVideo();
+ }
+
+ public boolean isAudio() {
+ return file.isAudio();
+ }
+
+ public String getFormat() {
+ return file.getFormat();
+ }
+
+ public void setFormat(String format) {
+ file.setFormat(format);
+ }
+
+ public boolean isDirectory() {
+ return file.isDirectory();
+ }
+
+ public boolean isFile() {
+ return file.isFile();
+ }
+
+ public boolean isAlbum() {
+ return file.isAlbum();
+ }
+
+ public String getTitle() {
+ return file.getTitle();
+ }
+
+ public void setTitle(String title) {
+ file.setTitle(title);
+ }
+
+ public String getAlbumName() {
+ return file.getAlbumName();
+ }
+
+ public void setAlbumName(String album) {
+ file.setAlbumName(album);
+ }
+
+ public String getArtist() {
+ return file.getArtist();
+ }
+
+ public void setArtist(String artist) {
+ file.setArtist(artist);
+ }
+
+ public String getAlbumArtist() {
+ return file.getAlbumArtist();
+ }
+
+ public void setAlbumArtist(String albumArtist) {
+ file.setAlbumArtist(albumArtist);
+ }
+
+ public String getName() {
+ return file.getName();
+ }
+
+ public Integer getDiscNumber() {
+ return file.getDiscNumber();
+ }
+
+ public void setDiscNumber(Integer discNumber) {
+ file.setDiscNumber(discNumber);
+ }
+
+ public Integer getTrackNumber() {
+ return file.getTrackNumber();
+ }
+
+ public void setTrackNumber(Integer trackNumber) {
+ file.setTrackNumber(trackNumber);
+ }
+
+ public Integer getYear() {
+ return file.getYear();
+ }
+
+ public void setYear(Integer year) {
+ file.setYear(year);
+ }
+
+ public String getGenre() {
+ return file.getGenre();
+ }
+
+ public void setGenre(String genre) {
+ file.setGenre(genre);
+ }
+
+ public Integer getBitRate() {
+ return file.getBitRate();
+ }
+
+ public void setBitRate(Integer bitRate) {
+ file.setBitRate(bitRate);
+ }
+
+ public boolean isVariableBitRate() {
+ return file.isVariableBitRate();
+ }
+
+ public void setVariableBitRate(boolean variableBitRate) {
+ file.setVariableBitRate(variableBitRate);
+ }
+
+ public Integer getDurationSeconds() {
+ return file.getDurationSeconds();
+ }
+
+ public void setDurationSeconds(Integer durationSeconds) {
+ file.setDurationSeconds(durationSeconds);
+ }
+
+ public String getDurationString() {
+ return file.getDurationString();
+ }
+
+ public Long getFileSize() {
+ return file.getFileSize();
+ }
+
+ public void setFileSize(Long fileSize) {
+ file.setFileSize(fileSize);
+ }
+
+ public Integer getWidth() {
+ return file.getWidth();
+ }
+
+ public void setWidth(Integer width) {
+ file.setWidth(width);
+ }
+
+ public Integer getHeight() {
+ return file.getHeight();
+ }
+
+ public void setHeight(Integer height) {
+ file.setHeight(height);
+ }
+
+ public String getCoverArtPath() {
+ return file.getCoverArtPath();
+ }
+
+ public void setCoverArtPath(String coverArtPath) {
+ file.setCoverArtPath(coverArtPath);
+ }
+
+ public String getParentPath() {
+ return file.getParentPath();
+ }
+
+ public void setParentPath(String parentPath) {
+ file.setParentPath(parentPath);
+ }
+
+ public File getParentFile() {
+ return file.getParentFile();
+ }
+
+ public int getPlayCount() {
+ return file.getPlayCount();
+ }
+
+ public void setPlayCount(int playCount) {
+ file.setPlayCount(playCount);
+ }
+
+ public Date getLastPlayed() {
+ return file.getLastPlayed();
+ }
+
+ public void setLastPlayed(Date lastPlayed) {
+ file.setLastPlayed(lastPlayed);
+ }
+
+ public String getComment() {
+ return file.getComment();
+ }
+
+ public void setComment(String comment) {
+ file.setComment(comment);
+ }
+
+ public Date getCreated() {
+ return file.getCreated();
+ }
+
+ public void setCreated(Date created) {
+ file.setCreated(created);
+ }
+
+ public Date getChanged() {
+ return file.getChanged();
+ }
+
+ public void setChanged(Date changed) {
+ file.setChanged(changed);
+ }
+
+ public Date getLastScanned() {
+ return file.getLastScanned();
+ }
+
+ public void setLastScanned(Date lastScanned) {
+ file.setLastScanned(lastScanned);
+ }
+
+ public Date getStarredDate() {
+ return file.getStarredDate();
+ }
+
+ public void setStarredDate(Date starredDate) {
+ file.setStarredDate(starredDate);
+ }
+
+ public Date getChildrenLastUpdated() {
+ return file.getChildrenLastUpdated();
+ }
+
+ public void setChildrenLastUpdated(Date childrenLastUpdated) {
+ file.setChildrenLastUpdated(childrenLastUpdated);
+ }
+
+ public boolean isPresent() {
+ return file.isPresent();
+ }
+
+ public void setPresent(boolean present) {
+ file.setPresent(present);
+ }
+
+ public int getVersion() {
+ return file.getVersion();
+ }
+
+ public File getCoverArtFile() {
+ return file.getCoverArtFile();
+ }
+
+ public static List toIdList(List from) {
+ return MediaFile.toIdList(from);
+ }
+
+ public static Function toId() {
+ return MediaFile.toId();
+ }
+}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/security/GlobalSecurityConfig.java b/libresonic-main/src/main/java/org/libresonic/player/security/GlobalSecurityConfig.java
new file mode 100644
index 00000000..9920f1c3
--- /dev/null
+++ b/libresonic-main/src/main/java/org/libresonic/player/security/GlobalSecurityConfig.java
@@ -0,0 +1,169 @@
+package org.libresonic.player.security;
+
+import org.apache.commons.lang3.StringUtils;
+import org.libresonic.player.service.JWTSecurityService;
+import org.libresonic.player.service.SecurityService;
+import org.libresonic.player.service.SettingsService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.security.SecurityProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.authentication.configurers.GlobalAuthenticationConfigurerAdapter;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+
+@Configuration
+@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
+@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
+public class GlobalSecurityConfig extends GlobalAuthenticationConfigurerAdapter {
+
+ private static Logger logger = LoggerFactory.getLogger(GlobalSecurityConfig.class);
+
+ static final String FAILURE_URL = "/login?error=1";
+
+ @Autowired
+ private SecurityService securityService;
+
+ @Autowired
+ private CsrfSecurityRequestMatcher csrfSecurityRequestMatcher;
+
+ @Autowired
+ LoginFailureLogger loginFailureLogger;
+
+ @Autowired
+ SettingsService settingsService;
+
+ @Autowired
+ LibresonicUserDetailsContextMapper libresonicUserDetailsContextMapper;
+
+
+ @Autowired
+ public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
+ if (settingsService.isLdapEnabled()) {
+ auth.ldapAuthentication()
+ .contextSource()
+ .managerDn(settingsService.getLdapManagerDn())
+ .managerPassword(settingsService.getLdapManagerPassword())
+ .url(settingsService.getLdapUrl())
+ .and()
+ .userSearchFilter(settingsService.getLdapSearchFilter())
+ .userDetailsContextMapper(libresonicUserDetailsContextMapper);
+ }
+ auth.userDetailsService(securityService);
+ String jwtKey = settingsService.getJWTKey();
+ if(StringUtils.isBlank(jwtKey)) {
+ logger.warn("Generating new jwt key");
+ jwtKey = JWTSecurityService.generateKey();
+ settingsService.setJWTKey(jwtKey);
+ settingsService.save();
+ }
+ auth.authenticationProvider(new JWTAuthenticationProvider(jwtKey));
+ }
+
+
+ @Configuration
+ @Order(1)
+ public class ExtSecurityConfiguration extends WebSecurityConfigurerAdapter {
+
+ public ExtSecurityConfiguration() {
+ super(true);
+ }
+
+ @Bean(name = "jwtAuthenticationFilter")
+ public JWTRequestParameterProcessingFilter jwtAuthFilter() throws Exception {
+ return new JWTRequestParameterProcessingFilter(authenticationManager(), FAILURE_URL);
+ }
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+
+ http = http.addFilter(new WebAsyncManagerIntegrationFilter());
+ http = http.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);
+
+ http
+ .antMatcher("/ext/**")
+ .csrf().requireCsrfProtectionMatcher(csrfSecurityRequestMatcher).and()
+ .headers().frameOptions().sameOrigin().and()
+ .authorizeRequests()
+ .antMatchers("/ext/stream/**", "/ext/coverArt.view", "/ext/share/**", "/ext/hls/**")
+ .hasAnyRole("TEMP", "USER").and()
+ .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
+ .exceptionHandling().and()
+ .securityContext().and()
+ .requestCache().and()
+ .anonymous().and()
+ .servletApi();
+ }
+ }
+
+ @Configuration
+ @Order(2)
+ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+
+ RESTRequestParameterProcessingFilter restAuthenticationFilter = new RESTRequestParameterProcessingFilter();
+ restAuthenticationFilter.setAuthenticationManager(authenticationManagerBean());
+ restAuthenticationFilter.setSecurityService(securityService);
+ restAuthenticationFilter.setLoginFailureLogger(loginFailureLogger);
+ http = http.addFilterBefore(restAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+
+ http
+ .csrf()
+ .requireCsrfProtectionMatcher(csrfSecurityRequestMatcher)
+ .and().headers()
+ .frameOptions()
+ .sameOrigin()
+ .and().authorizeRequests()
+ .antMatchers("recover.view", "accessDenied.view",
+ "/style/**", "/icons/**", "/flash/**", "/script/**",
+ "/sonos/**", "/crossdomain.xml", "/login", "/error")
+ .permitAll()
+ .antMatchers("/personalSettings.view", "/passwordSettings.view",
+ "/playerSettings.view", "/shareSettings.view", "/passwordSettings.view")
+ .hasRole("SETTINGS")
+ .antMatchers("/generalSettings.view", "/advancedSettings.view", "/userSettings.view",
+ "/musicFolderSettings.view", "/databaseSettings.view")
+ .hasRole("ADMIN")
+ .antMatchers("/deletePlaylist.view", "/savePlaylist.view")
+ .hasRole("PLAYLIST")
+ .antMatchers("/download.view")
+ .hasRole("DOWNLOAD")
+ .antMatchers("/upload.view")
+ .hasRole("UPLOAD")
+ .antMatchers("/createShare.view")
+ .hasRole("SHARE")
+ .antMatchers("/changeCoverArt.view", "/editTags.view")
+ .hasRole("COVERART")
+ .antMatchers("/setMusicFileInfo.view")
+ .hasRole("COMMENT")
+ .antMatchers("/podcastReceiverAdmin.view")
+ .hasRole("PODCAST")
+ .antMatchers("/**")
+ .hasRole("USER")
+ .anyRequest().authenticated()
+ .and().formLogin()
+ .loginPage("/login")
+ .permitAll()
+ .defaultSuccessUrl("/index.view", true)
+ .failureUrl(FAILURE_URL)
+ .usernameParameter("j_username")
+ .passwordParameter("j_password")
+ // see http://docs.spring.io/spring-security/site/docs/3.2.4.RELEASE/reference/htmlsingle/#csrf-logout
+ .and().logout().logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")).logoutSuccessUrl(
+ "/login?logout")
+ .and().rememberMe().key("libresonic");
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/libresonic-main/src/main/java/org/libresonic/player/security/JWTAuthenticationProvider.java b/libresonic-main/src/main/java/org/libresonic/player/security/JWTAuthenticationProvider.java
new file mode 100644
index 00000000..f422002d
--- /dev/null
+++ b/libresonic-main/src/main/java/org/libresonic/player/security/JWTAuthenticationProvider.java
@@ -0,0 +1,100 @@
+package org.libresonic.player.security;
+
+import com.auth0.jwt.interfaces.Claim;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.google.common.collect.MapDifference;
+import com.google.common.collect.Maps;
+import org.apache.commons.lang3.StringUtils;
+import org.libresonic.player.service.JWTSecurityService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.InsufficientAuthenticationException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+public class JWTAuthenticationProvider implements AuthenticationProvider {
+
+ private static final Logger logger = LoggerFactory.getLogger(JWTAuthenticationProvider.class);
+
+ private final String jwtKey;
+
+ public JWTAuthenticationProvider(String jwtSignAndVerifyKey) {
+ this.jwtKey = jwtSignAndVerifyKey;
+ }
+
+ @Override
+ public Authentication authenticate(Authentication auth) throws AuthenticationException {
+ JWTAuthenticationToken authentication = (JWTAuthenticationToken) auth;
+ if(authentication.getCredentials() == null || !(authentication.getCredentials() instanceof String)) {
+ logger.error("Credentials not present");
+ return null;
+ }
+ String rawToken = (String) auth.getCredentials();
+ DecodedJWT token = JWTSecurityService.verify(jwtKey, rawToken);
+ Claim path = token.getClaim(JWTSecurityService.CLAIM_PATH);
+ authentication.setAuthenticated(true);
+
+ // TODO:AD This is super unfortunate, but not sure there is a better way when using JSP
+ if(StringUtils.contains(authentication.getRequestedPath(), "/WEB-INF/jsp/")) {
+ logger.warn("BYPASSING AUTH FOR WEB-INF page");
+ } else
+
+ if(!roughlyEqual(path.asString(), authentication.getRequestedPath())) {
+ throw new InsufficientAuthenticationException("Credentials not valid for path " + authentication
+ .getRequestedPath() + ". They are valid for " + path.asString());
+ }
+
+ List authorities = new ArrayList<>();
+ authorities.add(new SimpleGrantedAuthority("IS_AUTHENTICATED_FULLY"));
+ authorities.add(new SimpleGrantedAuthority("ROLE_TEMP"));
+ return new JWTAuthenticationToken(authorities, rawToken, authentication.getRequestedPath());
+ }
+
+ private static boolean roughlyEqual(String expectedRaw, String requestedPathRaw) {
+ logger.debug("Comparing expected [{}] vs requested [{}]", expectedRaw, requestedPathRaw);
+ if(StringUtils.isEmpty(expectedRaw)) {
+ logger.debug("False: empty expected");
+ return false;
+ }
+ try {
+ UriComponents expected = UriComponentsBuilder.fromUriString(expectedRaw).build();
+ UriComponents requested = UriComponentsBuilder.fromUriString(requestedPathRaw).build();
+
+ if(!Objects.equals(expected.getPath(), requested.getPath())) {
+ logger.debug("False: expected path [{}] does not match requested path [{}]",
+ expected.getPath(), requested.getPath());
+ return false;
+ }
+
+ MapDifference> difference = Maps.difference(expected.getQueryParams(),
+ requested.getQueryParams());
+
+ if(difference.entriesDiffering().size() != 0 ||
+ difference.entriesOnlyOnLeft().size() != 0 ||
+ difference.entriesOnlyOnRight().size() != 1 ||
+ difference.entriesOnlyOnRight().get(JWTSecurityService.JWT_PARAM_NAME) == null) {
+ logger.debug("False: expected query params [{}] do not match requested query params [{}]", expected.getQueryParams(), requested.getQueryParams());
+ return false;
+ }
+
+ } catch(Exception e) {
+ logger.warn("Exception encountered while comparing paths", e);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean supports(Class> authentication) {
+ return JWTAuthenticationToken.class.isAssignableFrom(authentication);
+ }
+}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/security/JWTAuthenticationToken.java b/libresonic-main/src/main/java/org/libresonic/player/security/JWTAuthenticationToken.java
new file mode 100644
index 00000000..d321547f
--- /dev/null
+++ b/libresonic-main/src/main/java/org/libresonic/player/security/JWTAuthenticationToken.java
@@ -0,0 +1,31 @@
+package org.libresonic.player.security;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+
+public class JWTAuthenticationToken extends AbstractAuthenticationToken {
+ private final String token;
+ private String requestedPath;
+
+ public JWTAuthenticationToken(Collection extends GrantedAuthority> authorities, String token, String requestedPath) {
+ super(authorities);
+ this.token = token;
+ this.requestedPath = requestedPath;
+ }
+
+ @Override
+ public Object getCredentials() {
+ return token;
+ }
+
+ @Override
+ public Object getPrincipal() {
+ return "GERNIC_JWT_PRINICPLE";
+ }
+
+ public String getRequestedPath() {
+ return requestedPath;
+ }
+}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/security/JWTRequestParameterProcessingFilter.java b/libresonic-main/src/main/java/org/libresonic/player/security/JWTRequestParameterProcessingFilter.java
new file mode 100644
index 00000000..3489c885
--- /dev/null
+++ b/libresonic-main/src/main/java/org/libresonic/player/security/JWTRequestParameterProcessingFilter.java
@@ -0,0 +1,123 @@
+package org.libresonic.player.security;
+
+import org.libresonic.player.service.JWTSecurityService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.authentication.InternalAuthenticationServiceException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
+import org.springframework.util.StringUtils;
+
+import javax.servlet.*;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.util.Optional;
+
+public class JWTRequestParameterProcessingFilter implements Filter {
+ private static final Logger logger = LoggerFactory.getLogger(JWTRequestParameterProcessingFilter.class);
+ private final AuthenticationManager authenticationManager;
+ private final AuthenticationFailureHandler failureHandler;
+
+ protected JWTRequestParameterProcessingFilter(AuthenticationManager authenticationManager, String failureUrl) {
+ this.authenticationManager = authenticationManager; failureHandler = new SimpleUrlAuthenticationFailureHandler(failureUrl);
+ }
+
+ public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
+ Optional token = findToken(request);
+ if(token.isPresent()) {
+ return authenticationManager.authenticate(token.get());
+ }
+ throw new AuthenticationServiceException("Invalid auth method");
+ }
+
+ private static Optional findToken(HttpServletRequest request) {
+ String token = request.getParameter(JWTSecurityService.JWT_PARAM_NAME);
+ if(!StringUtils.isEmpty(token)) {
+ return Optional.of(new JWTAuthenticationToken(AuthorityUtils.NO_AUTHORITIES, token, request.getRequestURI() + "?" + request.getQueryString()));
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {
+
+ }
+
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException,
+ ServletException {
+ HttpServletRequest request = (HttpServletRequest) req;
+ HttpServletResponse response = (HttpServletResponse) resp;
+
+ if(!findToken(request).isPresent()) {
+ chain.doFilter(req, resp);
+ return;
+ }
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("Request is to process authentication");
+ }
+
+ Authentication authResult;
+
+ try {
+ authResult = attemptAuthentication(request, response);
+ if (authResult == null) {
+ // return immediately as subclass has indicated that it hasn't completed
+ // authentication
+ return;
+ }
+ }
+ catch (InternalAuthenticationServiceException failed) {
+ logger.error(
+ "An internal error occurred while trying to authenticate the user.",
+ failed);
+ unsuccessfulAuthentication(request, response, failed);
+
+ return;
+ }
+ catch (AuthenticationException failed) {
+ // Authentication failed
+ unsuccessfulAuthentication(request, response, failed);
+
+ return;
+ }
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ + authResult);
+ }
+
+ SecurityContextHolder.getContext().setAuthentication(authResult);
+
+ chain.doFilter(request, response);
+ }
+
+ protected void unsuccessfulAuthentication(HttpServletRequest request,
+ HttpServletResponse response, AuthenticationException failed)
+ throws IOException, ServletException {
+ SecurityContextHolder.clearContext();
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("Authentication request failed: " + failed.toString(), failed);
+ logger.debug("Updated SecurityContextHolder to contain null Authentication");
+ logger.debug("Delegating to authentication failure handler " + failureHandler);
+ }
+
+ failureHandler.onAuthenticationFailure(request, response, failed);
+ }
+
+ @Override
+ public void destroy() {
+
+ }
+
+}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/security/WebSecurityConfig.java b/libresonic-main/src/main/java/org/libresonic/player/security/WebSecurityConfig.java
deleted file mode 100644
index 6a340fa9..00000000
--- a/libresonic-main/src/main/java/org/libresonic/player/security/WebSecurityConfig.java
+++ /dev/null
@@ -1,112 +0,0 @@
-package org.libresonic.player.security;
-
-import org.libresonic.player.service.SecurityService;
-import org.libresonic.player.service.SettingsService;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.security.authentication.AuthenticationManager;
-import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
-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.configuration.WebSecurityConfigurerAdapter;
-import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
-import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
-
-@Configuration
-@EnableWebSecurity
-public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
-
- @Autowired
- private SecurityService securityService;
-
- @Autowired
- private CsrfSecurityRequestMatcher csrfSecurityRequestMatcher;
-
- @Autowired
- LoginFailureLogger loginFailureLogger;
-
- @Autowired
- SettingsService settingsService;
-
- @Autowired
- LibresonicUserDetailsContextMapper libresonicUserDetailsContextMapper;
-
- @Override
- @Bean(name = "authenticationManager")
- public AuthenticationManager authenticationManagerBean() throws Exception {
- return super.authenticationManagerBean();
- }
-
- @Autowired
- public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
- if (settingsService.isLdapEnabled()) {
- auth.ldapAuthentication()
- .contextSource()
- .managerDn(settingsService.getLdapManagerDn())
- .managerPassword(settingsService.getLdapManagerPassword())
- .url(settingsService.getLdapUrl())
- .and()
- .userSearchFilter(settingsService.getLdapSearchFilter())
- .userDetailsContextMapper(libresonicUserDetailsContextMapper);
- }
- auth.userDetailsService(securityService);
- }
-
-
- @Override
- protected void configure(HttpSecurity http) throws Exception {
-
- RESTRequestParameterProcessingFilter restAuthenticationFilter = new RESTRequestParameterProcessingFilter();
- restAuthenticationFilter.setAuthenticationManager((AuthenticationManager) getApplicationContext().getBean("authenticationManager"));
- restAuthenticationFilter.setSecurityService(securityService);
- restAuthenticationFilter.setLoginFailureLogger(loginFailureLogger);
- http = http.addFilterBefore(restAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
-
- http
- .csrf()
- .requireCsrfProtectionMatcher(csrfSecurityRequestMatcher)
- .and().headers()
- .frameOptions()
- .sameOrigin()
- .and().authorizeRequests()
- .antMatchers("recover.view", "accessDenied.view",
- "coverArt.view", "/hls/**", "/stream/**", "/ws/**",
- "/share/**", "/style/**", "/icons/**",
- "/flash/**", "/script/**", "/sonos/**", "/crossdomain.xml", "/login")
- .permitAll()
- .antMatchers("/personalSettings.view", "/passwordSettings.view",
- "/playerSettings.view", "/shareSettings.view","/passwordSettings.view")
- .hasRole("SETTINGS")
- .antMatchers("/generalSettings.view","/advancedSettings.view","/userSettings.view",
- "/musicFolderSettings.view", "/databaseSettings.view")
- .hasRole("ADMIN")
- .antMatchers("/deletePlaylist.view","/savePlaylist.view")
- .hasRole("PLAYLIST")
- .antMatchers("/download.view")
- .hasRole("DOWNLOAD")
- .antMatchers("/upload.view")
- .hasRole("UPLOAD")
- .antMatchers("/createShare.view")
- .hasRole("SHARE")
- .antMatchers("/changeCoverArt.view","/editTags.view")
- .hasRole("COVERART")
- .antMatchers("/setMusicFileInfo.view")
- .hasRole("COMMENT")
- .antMatchers("/podcastReceiverAdmin.view")
- .hasRole("PODCAST")
- .antMatchers("/**")
- .hasRole("USER")
- .anyRequest().authenticated()
- .and().formLogin()
- .loginPage("/login")
- .permitAll()
- .defaultSuccessUrl("/index.view", true)
- .failureUrl("/login?error=1")
- .usernameParameter("j_username")
- .passwordParameter("j_password")
- // see http://docs.spring.io/spring-security/site/docs/3.2.4.RELEASE/reference/htmlsingle/#csrf-logout
- .and().logout().logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")).logoutSuccessUrl("/login?logout")
- .and().rememberMe().key("libresonic");
- }
-}
\ No newline at end of file
diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/JWTSecurityService.java b/libresonic-main/src/main/java/org/libresonic/player/service/JWTSecurityService.java
new file mode 100644
index 00000000..25e5f860
--- /dev/null
+++ b/libresonic-main/src/main/java/org/libresonic/player/service/JWTSecurityService.java
@@ -0,0 +1,80 @@
+package org.libresonic.player.service;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTVerifier;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.time.DateUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.io.UnsupportedEncodingException;
+import java.math.BigInteger;
+import java.security.SecureRandom;
+import java.util.Date;
+
+public class JWTSecurityService {
+ private static final Logger logger = LoggerFactory.getLogger(JWTSecurityService.class);
+
+ public static final String JWT_PARAM_NAME = "jwt";
+ public static final String CLAIM_PATH = "path";
+ // TODO make this configurable
+ public static final int DEFAULT_DAYS_VALID_FOR = 7;
+ private static SecureRandom secureRandom = new SecureRandom();
+
+ private final SettingsService settingsService;
+
+ public JWTSecurityService(SettingsService settingsService) {
+ this.settingsService = settingsService;
+ }
+
+ public static String generateKey() {
+ BigInteger randomInt = new BigInteger(130, secureRandom);
+ return randomInt.toString(32);
+ }
+
+ public static Algorithm getAlgorithm(String jwtKey) {
+ try {
+ return Algorithm.HMAC256(jwtKey);
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static String createToken(String jwtKey, String path, Date expireDate) {
+ UriComponents components = UriComponentsBuilder.fromUriString(path).build();
+ String query = components.getQuery();
+ String claim = components.getPath() + (!StringUtils.isBlank(query) ? "?" + components.getQuery() : "");
+ logger.debug("Creating token with claim " + claim);
+ return JWT.create()
+ .withClaim(CLAIM_PATH, claim)
+ .withExpiresAt(expireDate)
+ .sign(getAlgorithm(jwtKey));
+ }
+
+ public UriComponentsBuilder addJWTToken(UriComponentsBuilder builder) {
+ return addJWTToken(builder, DateUtils.addDays(new Date(), DEFAULT_DAYS_VALID_FOR));
+ }
+
+ public UriComponentsBuilder addJWTToken(UriComponentsBuilder builder, Date expires) {
+ String token = JWTSecurityService.createToken(
+ settingsService.getJWTKey(),
+ builder.toUriString(),
+ expires);
+ builder.queryParam(JWTSecurityService.JWT_PARAM_NAME, token);
+ return builder;
+ }
+
+ public static DecodedJWT verify(String jwtKey, String token) {
+ Algorithm algorithm = JWTSecurityService.getAlgorithm(jwtKey);
+ JWTVerifier verifier = JWT.require(algorithm).build();
+ return verifier.verify(token);
+ }
+
+ public DecodedJWT verify(String credentials) {
+ return verify(settingsService.getJWTKey(), credentials);
+ }
+}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/ServiceLocator.java b/libresonic-main/src/main/java/org/libresonic/player/service/ServiceLocator.java
index f5f4a161..acb48562 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/service/ServiceLocator.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/service/ServiceLocator.java
@@ -28,10 +28,6 @@ package org.libresonic.player.service;
public class ServiceLocator {
private static SettingsService settingsService;
- private static VersionService versionService;
-
- private ServiceLocator() {
- }
public static SettingsService getSettingsService() {
return settingsService;
@@ -40,13 +36,5 @@ public class ServiceLocator {
public static void setSettingsService(SettingsService settingsService) {
ServiceLocator.settingsService = settingsService;
}
-
- public static VersionService getVersionService() {
- return versionService;
- }
-
- public static void setVersionService(VersionService versionService) {
- ServiceLocator.versionService = versionService;
- }
}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java b/libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java
index 44139b3b..040366b8 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/service/SettingsService.java
@@ -98,6 +98,7 @@ public class SettingsService {
private static final String KEY_SONOS_ENABLED = "SonosEnabled";
private static final String KEY_SONOS_SERVICE_NAME = "SonosServiceName";
private static final String KEY_SONOS_SERVICE_ID = "SonosServiceId";
+ private static final String KEY_JWT_KEY = "JWTKey";
private static final String KEY_SMTP_SERVER = "SmtpServer";
private static final String KEY_SMTP_ENCRYPTION = "SmtpEncryption";
@@ -117,6 +118,7 @@ public class SettingsService {
private static final String KEY_DATABASE_USERTABLE_QUOTE = "DatabaseUsertableQuote";
// Default values.
+ private static final String DEFAULT_JWT_KEY = null;
private static final String DEFAULT_INDEX_STRING = "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ)";
private static final String DEFAULT_IGNORED_ARTICLES = "The El La Los Las Le Les";
private static final String DEFAULT_SHORTCUTS = "New Incoming Podcast";
@@ -1338,6 +1340,14 @@ public class SettingsService {
setString(KEY_DATABASE_USERTABLE_QUOTE, usertableQuote);
}
+ public String getJWTKey() {
+ return getString(KEY_JWT_KEY, DEFAULT_JWT_KEY);
+ }
+
+ public void setJWTKey(String jwtKey) {
+ setString(KEY_JWT_KEY, jwtKey);
+ }
+
public void setConfigurationService(ApacheCommonsConfigurationService configurationService) {
this.configurationService = configurationService;
}
diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/ShareService.java b/libresonic-main/src/main/java/org/libresonic/player/service/ShareService.java
index d25d1bd8..6f995e69 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/service/ShareService.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/service/ShareService.java
@@ -23,17 +23,12 @@ import org.apache.commons.lang.ObjectUtils;
import org.apache.commons.lang.RandomStringUtils;
import org.libresonic.player.Logger;
import org.libresonic.player.dao.ShareDao;
-import org.libresonic.player.domain.MediaFile;
-import org.libresonic.player.domain.MusicFolder;
-import org.libresonic.player.domain.Share;
-import org.libresonic.player.domain.User;
+import org.libresonic.player.domain.*;
+import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.http.HttpServletRequest;
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Date;
-import java.util.List;
+import java.util.*;
/**
* Provides services for sharing media.
@@ -47,8 +42,8 @@ public class ShareService {
private ShareDao shareDao;
private SecurityService securityService;
- private SettingsService settingsService;
private MediaFileService mediaFileService;
+ private JWTSecurityService jwtSecurityService;
public List getAllShares() {
return shareDao.getAllShares();
@@ -115,12 +110,9 @@ public class ShareService {
shareDao.deleteShare(id);
}
- public String getShareBaseUrl(HttpServletRequest request) {
- return NetworkService.getBaseUrl(request) + "/share/";
- }
-
public String getShareUrl(HttpServletRequest request, Share share) {
- return getShareBaseUrl(request) + share.getName();
+ String shareUrl = NetworkService.getBaseUrl(request) + "/ext/share/" + share.getName();
+ return jwtSecurityService.addJWTToken(UriComponentsBuilder.fromUriString(shareUrl), share.getExpires()).build().toUriString();
}
public void setSecurityService(SecurityService securityService) {
@@ -131,11 +123,11 @@ public class ShareService {
this.shareDao = shareDao;
}
- public void setSettingsService(SettingsService settingsService) {
- this.settingsService = settingsService;
- }
-
public void setMediaFileService(MediaFileService mediaFileService) {
this.mediaFileService = mediaFileService;
}
+
+ public void setJwtSecurityService(JWTSecurityService jwtSecurityService) {
+ this.jwtSecurityService = jwtSecurityService;
+ }
}
\ No newline at end of file
diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/VersionService.java b/libresonic-main/src/main/java/org/libresonic/player/service/VersionService.java
index 5c35a5cc..545b47ff 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/service/VersionService.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/service/VersionService.java
@@ -68,10 +68,6 @@ public class VersionService {
*/
private static final String VERSION_URL = "http://libresonic.org/release/version.txt";
- public void init() {
- ServiceLocator.setVersionService(this);
- }
-
/**
* Returns the version number for the locally installed Libresonic version.
*
diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/upnp/FolderBasedContentDirectory.java b/libresonic-main/src/main/java/org/libresonic/player/service/upnp/FolderBasedContentDirectory.java
index feec52d4..e7494eeb 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/service/upnp/FolderBasedContentDirectory.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/service/upnp/FolderBasedContentDirectory.java
@@ -33,6 +33,7 @@ import org.libresonic.player.domain.*;
import org.libresonic.player.service.MediaFileService;
import org.libresonic.player.service.PlaylistService;
import org.libresonic.player.util.Util;
+import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.net.URISyntaxException;
@@ -260,7 +261,12 @@ public class FolderBasedContentDirectory extends LibresonicContentDirectory {
}
private URI getAlbumArtUrl(MediaFile album) throws URISyntaxException {
- return new URI(getBaseUrl() + "coverArt.view?id=" + album.getId() + "&size=" + CoverArtScheme.LARGE.getSize());
+ return jwtSecurityService.addJWTToken(UriComponentsBuilder.fromUriString(getBaseUrl() + "/ext/coverArt.view")
+ .queryParam("id", album.getId())
+ .queryParam("size", CoverArtScheme.LARGE.getSize()))
+ .build()
+ .encode()
+ .toUri();
}
public void setMediaFileService(MediaFileService mediaFileService) {
diff --git a/libresonic-main/src/main/java/org/libresonic/player/service/upnp/LibresonicContentDirectory.java b/libresonic-main/src/main/java/org/libresonic/player/service/upnp/LibresonicContentDirectory.java
index 5f22d1b8..587989c8 100644
--- a/libresonic-main/src/main/java/org/libresonic/player/service/upnp/LibresonicContentDirectory.java
+++ b/libresonic-main/src/main/java/org/libresonic/player/service/upnp/LibresonicContentDirectory.java
@@ -30,11 +30,13 @@ import org.fourthline.cling.support.model.Res;
import org.fourthline.cling.support.model.SortCriterion;
import org.libresonic.player.domain.MediaFile;
import org.libresonic.player.domain.Player;
+import org.libresonic.player.service.JWTSecurityService;
import org.libresonic.player.service.PlayerService;
import org.libresonic.player.service.SettingsService;
import org.libresonic.player.service.TranscodingService;
import org.libresonic.player.util.StringUtil;
import org.seamless.util.MimeType;
+import org.springframework.web.util.UriComponentsBuilder;
/**
* @author Sindre Mehus
@@ -47,14 +49,23 @@ public abstract class LibresonicContentDirectory extends AbstractContentDirector
protected SettingsService settingsService;
private PlayerService playerService;
private TranscodingService transcodingService;
+ protected JWTSecurityService jwtSecurityService;
protected Res createResourceForSong(MediaFile song) {
Player player = playerService.getGuestPlayer(null);
- String url = getBaseUrl() + "stream?id=" + song.getId() + "&player=" + player.getId();
+
+ UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(getBaseUrl() + "/ext/stream")
+ .queryParam("id", song.getId())
+ .queryParam("player", player.getId());
+
if (song.isVideo()) {
- url += "&format=" + TranscodingService.FORMAT_RAW;
+ builder.queryParam("format", TranscodingService.FORMAT_RAW);
}
+ jwtSecurityService.addJWTToken(builder);
+
+ String url = builder.toUriString();
+
String suffix = song.isVideo() ? FilenameUtils.getExtension(song.getPath()) : transcodingService.getSuffix(player, song, null);
String mimeTypeString = StringUtil.getMimeType(suffix);
MimeType mimeType = mimeTypeString == null ? null : MimeType.valueOf(mimeTypeString);
@@ -123,4 +134,8 @@ public abstract class LibresonicContentDirectory extends AbstractContentDirector
public void setSettingsService(SettingsService settingsService) {
this.settingsService = settingsService;
}
+
+ public void setJwtSecurityService(JWTSecurityService jwtSecurityService) {
+ this.jwtSecurityService = jwtSecurityService;
+ }
}
diff --git a/libresonic-main/src/main/resources/applicationContext-service.xml b/libresonic-main/src/main/resources/applicationContext-service.xml
index 44f3aabe..87ef1f9c 100644
--- a/libresonic-main/src/main/resources/applicationContext-service.xml
+++ b/libresonic-main/src/main/resources/applicationContext-service.xml
@@ -137,7 +137,7 @@
-
+
@@ -166,9 +166,9 @@
-
+
@@ -198,6 +198,7 @@
+
@@ -299,4 +300,8 @@
+
+
+
+
diff --git a/libresonic-main/src/main/webapp/WEB-INF/jsp/externalPlayer.jsp b/libresonic-main/src/main/webapp/WEB-INF/jsp/externalPlayer.jsp
index 13898bfd..fd1e9557 100644
--- a/libresonic-main/src/main/webapp/WEB-INF/jsp/externalPlayer.jsp
+++ b/libresonic-main/src/main/webapp/WEB-INF/jsp/externalPlayer.jsp
@@ -10,12 +10,8 @@
-
-
-
-
-
+