From f7bd43136b6275de1206fd6be500f2961e0e6623 Mon Sep 17 00:00:00 2001 From: Andrew DeMaria Date: Sat, 1 Apr 2017 00:21:27 -0600 Subject: [PATCH] Lock Down unsecured urls Signed-off-by: Andrew DeMaria --- libresonic-main/pom.xml | 7 +- .../player/controller/ControllerUtils.java | 16 + .../player/controller/CoverArtController.java | 2 +- .../controller/ExternalPlayerController.java | 79 +++-- .../player/controller/HLSController.java | 30 +- .../controller/ShareSettingsController.java | 13 +- .../player/controller/StreamController.java | 10 +- .../player/domain/MediaFileWithUrlInfo.java | 324 ++++++++++++++++++ .../player/security/GlobalSecurityConfig.java | 169 +++++++++ .../security/JWTAuthenticationProvider.java | 100 ++++++ .../security/JWTAuthenticationToken.java | 31 ++ .../JWTRequestParameterProcessingFilter.java | 123 +++++++ .../player/security/WebSecurityConfig.java | 112 ------ .../player/service/JWTSecurityService.java | 80 +++++ .../player/service/ServiceLocator.java | 12 - .../player/service/SettingsService.java | 10 + .../player/service/ShareService.java | 28 +- .../player/service/VersionService.java | 4 - .../upnp/FolderBasedContentDirectory.java | 8 +- .../upnp/LibresonicContentDirectory.java | 19 +- .../resources/applicationContext-service.xml | 9 +- .../webapp/WEB-INF/jsp/externalPlayer.jsp | 21 +- .../main/webapp/WEB-INF/jsp/shareSettings.jsp | 2 +- .../service/JWTSecurityServiceTest.java | 62 ++++ 24 files changed, 1060 insertions(+), 211 deletions(-) create mode 100644 libresonic-main/src/main/java/org/libresonic/player/domain/MediaFileWithUrlInfo.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/security/GlobalSecurityConfig.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/security/JWTAuthenticationProvider.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/security/JWTAuthenticationToken.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/security/JWTRequestParameterProcessingFilter.java delete mode 100644 libresonic-main/src/main/java/org/libresonic/player/security/WebSecurityConfig.java create mode 100644 libresonic-main/src/main/java/org/libresonic/player/service/JWTSecurityService.java create mode 100644 libresonic-main/src/test/java/org/libresonic/player/service/JWTSecurityServiceTest.java 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 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 @@ - - - - - +