added authentication via api key for some limited api methods (fix #64)

This commit is contained in:
Athou
2013-05-01 21:56:59 +02:00
parent 52a8ea1920
commit 5dcc923cf1
14 changed files with 148 additions and 40 deletions

View File

@@ -29,4 +29,19 @@ public class UserDAO extends GenericDAO<User> {
return user; return user;
} }
public User findByApiKey(String key) {
CriteriaQuery<User> query = builder.createQuery(getType());
Root<User> root = query.from(getType());
query.where(builder.equal(root.get(User_.apiKey), key));
TypedQuery<User> q = em.createQuery(query);
User user = null;
try {
user = q.getSingleResult();
} catch (NoResultException e) {
user = null;
}
return user;
}
} }

View File

@@ -31,6 +31,9 @@ public class User extends AbstractModel {
@Column(length = 256, nullable = false) @Column(length = 256, nullable = false)
private byte[] password; private byte[] password;
@Column(length = 40, unique = true)
private String apiKey;
@Column(length = 8, nullable = false) @Column(length = 8, nullable = false)
private byte[] salt; private byte[] salt;
@@ -99,4 +102,12 @@ public class User extends AbstractModel {
this.lastLogin = lastLogin; this.lastLogin = lastLogin;
} }
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
} }

View File

@@ -36,10 +36,6 @@ public class CommaFeedSession extends AuthenticatedWebSession {
return user; return user;
} }
public void setUser(User user) {
this.user = user;
}
public static CommaFeedSession get() { public static CommaFeedSession get() {
return (CommaFeedSession) Session.get(); return (CommaFeedSession) Session.get();
} }
@@ -52,6 +48,11 @@ public class CommaFeedSession extends AuthenticatedWebSession {
@Override @Override
public boolean authenticate(String userName, String password) { public boolean authenticate(String userName, String password) {
User user = userService.login(userName, password); User user = userService.login(userName, password);
setUser(user);
return user != null;
}
public void setUser(User user) {
if (user == null) { if (user == null) {
this.user = null; this.user = null;
this.roles = new Roles(); this.roles = new Roles();
@@ -64,7 +65,6 @@ public class CommaFeedSession extends AuthenticatedWebSession {
this.user = user; this.user = user;
this.roles = new Roles(roleSet.toArray(new String[0])); this.roles = new Roles(roleSet.toArray(new String[0]));
} }
return user != null;
} }
} }

View File

@@ -22,4 +22,7 @@ public @interface SecurityCheck {
*/ */
@Nonbinding @Nonbinding
Role value() default Role.USER; Role value() default Role.USER;
@Nonbinding
boolean apiKeyAllowed() default false;
} }

View File

@@ -24,6 +24,9 @@ public class UserModel implements Serializable {
@ApiProperty("user email, if any") @ApiProperty("user email, if any")
private String email; private String email;
@ApiProperty("api key")
private String apiKey;
@ApiProperty(value = "user password, never returned by the api") @ApiProperty(value = "user password, never returned by the api")
private String password; private String password;
@@ -81,4 +84,12 @@ public class UserModel implements Serializable {
this.email = email; this.email = email;
} }
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
} }

View File

@@ -18,6 +18,9 @@ public class ProfileModificationRequest {
@ApiProperty(value = "changes password of the user, if specified") @ApiProperty(value = "changes password of the user, if specified")
private String password; private String password;
@ApiProperty(value = "generate a new api key")
private boolean newApiKey;
public String getEmail() { public String getEmail() {
return email; return email;
} }
@@ -34,4 +37,12 @@ public class ProfileModificationRequest {
this.password = password; this.password = password;
} }
public boolean isNewApiKey() {
return newApiKey;
}
public void setNewApiKey(boolean newApiKey) {
this.newApiKey = newApiKey;
}
} }

View File

@@ -124,23 +124,38 @@ public abstract class AbstractREST {
.fetchCreateAndSetSession(cycle); .fetchCreateAndSetSession(cycle);
if (session.getUser() == null) { if (session.getUser() == null) {
cookieLogin(app, session);
}
if (session.getUser() == null) {
basicHttpLogin(swreq, session);
}
}
private void cookieLogin(CommaFeedApplication app, CommaFeedSession session) {
IAuthenticationStrategy authenticationStrategy = app IAuthenticationStrategy authenticationStrategy = app
.getSecuritySettings().getAuthenticationStrategy(); .getSecuritySettings().getAuthenticationStrategy();
String[] data = authenticationStrategy.load(); String[] data = authenticationStrategy.load();
if (data != null && data.length > 1) { if (data != null && data.length > 1) {
session.signIn(data[0], data[1]); session.signIn(data[0], data[1]);
} else { }
String value = swreq.getHeader(HttpHeaders.AUTHORIZATION); }
private void basicHttpLogin(ServletWebRequest req, CommaFeedSession session) {
String value = req.getHeader(HttpHeaders.AUTHORIZATION);
if (value != null && value.startsWith("Basic ")) { if (value != null && value.startsWith("Basic ")) {
value = value.substring(6); value = value.substring(6);
String decoded = new String(Base64.decodeBase64(value)); String decoded = new String(Base64.decodeBase64(value));
data = decoded.split(":"); String[] data = decoded.split(":");
if (data != null && data.length > 1) { if (data != null && data.length > 1) {
session.signIn(data[0], data[1]); session.signIn(data[0], data[1]);
} }
} }
} }
}
private void apiKeyLogin() {
String apiKey = request.getParameter("apiKey");
User user = userDAO.findByApiKey(apiKey);
CommaFeedSession.get().setUser(user);
} }
protected User getUser() { protected User getUser() {
@@ -149,19 +164,21 @@ public abstract class AbstractREST {
@AroundInvoke @AroundInvoke
public Object checkSecurity(InvocationContext context) throws Exception { public Object checkSecurity(InvocationContext context) throws Exception {
User user = getUser();
boolean allowed = true; boolean allowed = true;
User user = null;
Method method = context.getMethod(); Method method = context.getMethod();
SecurityCheck check = method.isAnnotationPresent(SecurityCheck.class) ? method
.getAnnotation(SecurityCheck.class) : method
.getDeclaringClass().getAnnotation(SecurityCheck.class);
if (method.isAnnotationPresent(SecurityCheck.class)) { if (check != null) {
allowed = checkRole(user, method.getAnnotation(SecurityCheck.class)); user = getUser();
} else if (method.getDeclaringClass().isAnnotationPresent( if (user == null && check.apiKeyAllowed()) {
SecurityCheck.class)) { apiKeyLogin();
allowed = checkRole( user = getUser();
user, }
method.getDeclaringClass().getAnnotation(
SecurityCheck.class)); allowed = checkRole(check.value());
} }
if (!allowed) { if (!allowed) {
if (user == null) { if (user == null) {
@@ -181,8 +198,7 @@ public abstract class AbstractREST {
return context.proceed(); return context.proceed();
} }
private boolean checkRole(User user, SecurityCheck annotation) { private boolean checkRole(Role requiredRole) {
Role requiredRole = annotation.value();
if (requiredRole == Role.NONE) { if (requiredRole == Role.NONE) {
return true; return true;
} }

View File

@@ -26,7 +26,9 @@ import org.slf4j.LoggerFactory;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus; import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.model.UserSettings.ReadingOrder; import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.frontend.SecurityCheck;
import com.commafeed.frontend.model.Category; import com.commafeed.frontend.model.Category;
import com.commafeed.frontend.model.Entries; import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry; import com.commafeed.frontend.model.Entry;
@@ -111,6 +113,7 @@ public class CategoryREST extends AbstractResourceREST {
@GET @GET
@ApiOperation(value = "Get category entries as feed", notes = "Get a feed of category entries") @ApiOperation(value = "Get category entries as feed", notes = "Get a feed of category entries")
@Produces(MediaType.APPLICATION_XML) @Produces(MediaType.APPLICATION_XML)
@SecurityCheck(value = Role.USER, apiKeyAllowed = true)
public String getCategoryEntriesAsFeed( public String getCategoryEntriesAsFeed(
@ApiParam(value = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id) { @ApiParam(value = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id) {

View File

@@ -31,14 +31,16 @@ import com.commafeed.backend.feeds.FetchedFeed;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus; import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.model.UserSettings.ReadingOrder; import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.frontend.SecurityCheck;
import com.commafeed.frontend.model.Entries; import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry; import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.FeedInfo; import com.commafeed.frontend.model.FeedInfo;
import com.commafeed.frontend.model.Subscription; import com.commafeed.frontend.model.Subscription;
import com.commafeed.frontend.model.request.FeedModificationRequest;
import com.commafeed.frontend.model.request.IDRequest; import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.frontend.model.request.MarkRequest; import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.frontend.model.request.FeedModificationRequest;
import com.commafeed.frontend.model.request.SubscribeRequest; import com.commafeed.frontend.model.request.SubscribeRequest;
import com.commafeed.frontend.rest.Enums.ReadType; import com.commafeed.frontend.rest.Enums.ReadType;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
@@ -98,6 +100,7 @@ public class FeedREST extends AbstractResourceREST {
@GET @GET
@ApiOperation(value = "Get feed entries as a feed", notes = "Get a feed of feed entries") @ApiOperation(value = "Get feed entries as a feed", notes = "Get a feed of feed entries")
@Produces(MediaType.APPLICATION_XML) @Produces(MediaType.APPLICATION_XML)
@SecurityCheck(value = Role.USER, apiKeyAllowed = true)
public String getFeedEntriesAsFeed( public String getFeedEntriesAsFeed(
@ApiParam(value = "id of the feed", required = true) @QueryParam("id") String id) { @ApiParam(value = "id of the feed", required = true) @QueryParam("id") String id) {

View File

@@ -1,11 +1,14 @@
package com.commafeed.frontend.rest.resources; package com.commafeed.frontend.rest.resources;
import java.util.UUID;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.POST; import javax.ws.rs.POST;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.Response.Status;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.StringUtils;
import com.commafeed.backend.StartupBean; import com.commafeed.backend.StartupBean;
@@ -78,6 +81,7 @@ public class UserREST extends AbstractResourceREST {
userModel.setName(user.getName()); userModel.setName(user.getName());
userModel.setEmail(user.getEmail()); userModel.setEmail(user.getEmail());
userModel.setEnabled(!user.isDisabled()); userModel.setEnabled(!user.isDisabled());
userModel.setApiKey(user.getApiKey());
for (UserRole role : userRoleDAO.findAll(user)) { for (UserRole role : userRoleDAO.findAll(user)) {
if (role.getRole() == Role.ADMIN) { if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true); userModel.setAdmin(true);
@@ -95,13 +99,24 @@ public class UserREST extends AbstractResourceREST {
if (StartupBean.USERNAME_DEMO.equals(user.getName())) { if (StartupBean.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.UNAUTHORIZED).build(); return Response.status(Status.UNAUTHORIZED).build();
} }
user.setEmail(request.getEmail()); user.setEmail(request.getEmail());
if (StringUtils.isNotBlank(request.getPassword())) { if (StringUtils.isNotBlank(request.getPassword())) {
byte[] password = encryptionService.getEncryptedPassword( byte[] password = encryptionService.getEncryptedPassword(
request.getPassword(), user.getSalt()); request.getPassword(), user.getSalt());
user.setPassword(password); user.setPassword(password);
user.setApiKey(generateKey(user));
}
if (request.isNewApiKey()) {
user.setApiKey(generateKey(user));
} }
userDAO.update(user); userDAO.update(user);
return Response.ok().build(); return Response.ok().build();
} }
private String generateKey(User user) {
byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID()
.toString(), user.getSalt());
return DigestUtils.sha1Hex(key);
}
} }

View File

@@ -184,10 +184,11 @@ function($scope, $timeout, $stateParams, $window, $location, $state, $route, Cat
}); });
}]); }]);
module.controller('FeedDetailsCtrl', ['$scope', '$state', '$stateParams', 'FeedService', 'CategoryService', '$dialog', module.controller('FeedDetailsCtrl', ['$scope', '$state', '$stateParams', 'FeedService', 'CategoryService', 'ProfileService', '$dialog',
function($scope, $state, $stateParams, FeedService, CategoryService, $dialog) { function($scope, $state, $stateParams, FeedService, CategoryService, ProfileService, $dialog) {
$scope.CategoryService = CategoryService; $scope.CategoryService = CategoryService;
$scope.user = ProfileService.get();
$scope.sub = FeedService.get({ $scope.sub = FeedService.get({
id : $stateParams._id id : $stateParams._id
@@ -250,9 +251,10 @@ module.controller('FeedDetailsCtrl', ['$scope', '$state', '$stateParams', 'FeedS
}; };
}]); }]);
module.controller('CategoryDetailsCtrl', ['$scope', '$state', '$stateParams', 'FeedService', 'CategoryService', '$dialog', module.controller('CategoryDetailsCtrl', ['$scope', '$state', '$stateParams', 'FeedService', 'CategoryService', 'ProfileService', '$dialog',
function($scope, $state, $stateParams, FeedService, CategoryService, $dialog) { function($scope, $state, $stateParams, FeedService, CategoryService, ProfileService, $dialog) {
$scope.CategoryService = CategoryService; $scope.CategoryService = CategoryService;
$scope.user = ProfileService.get();
$scope.isMeta = function() { $scope.isMeta = function() {
return parseInt($stateParams._id, 10) != $stateParams._id; return parseInt($stateParams._id, 10) != $stateParams._id;
@@ -264,7 +266,7 @@ module.controller('CategoryDetailsCtrl', ['$scope', '$state', '$stateParams', 'F
return elem.id != $scope.category.id; return elem.id != $scope.category.id;
}; };
CategoryService.get(function() { CategoryService.init(function() {
if ($scope.isMeta()) { if ($scope.isMeta()) {
$scope.category = { $scope.category = {
id : $stateParams._id, id : $stateParams._id,
@@ -785,7 +787,8 @@ function($scope, $location, ProfileService, AnalyticsService) {
} }
var o = { var o = {
email : $scope.user.email, email : $scope.user.email,
password : $scope.user.password password : $scope.user.password,
newApiKey : $scope.newApiKey
}; };
ProfileService.save(o, function() { ProfileService.save(o, function() {

View File

@@ -24,7 +24,8 @@
<div class="control-group"> <div class="control-group">
<label class="control-label">Feed URL</label> <label class="control-label">Feed URL</label>
<div class="controls horizontal-align"> <div class="controls horizontal-align">
<a href="{{'rest/category/entriesAsFeed?id=' + category.id}}" target="_blank">{{'rest/category/entriesAsFeed?id=' + category.id}}</a> <a ng-show="user.apiKey" href="{{'rest/category/entriesAsFeed?id=' + category.id + '&apiKey=' + user.apiKey}}" target="_blank">link</a>
<span ng-show="!user.apiKey">Generate an API key in your profile first.</span>
</div> </div>
</div> </div>

View File

@@ -37,7 +37,8 @@
<div class="control-group"> <div class="control-group">
<label class="control-label">Feed URL</label> <label class="control-label">Feed URL</label>
<div class="controls horizontal-align"> <div class="controls horizontal-align">
<a href="{{'rest/feed/entriesAsFeed?id=' + sub.id}}" target="_blank">{{'rest/feed/entriesAsFeed?id=' + sub.id}}</a> <a ng-show="user.apiKey" href="{{'rest/feed/entriesAsFeed?id=' + sub.id + '&apiKey=' + user.apiKey}}" target="_blank">link</a>
<span ng-show="!user.apiKey">Generate an API key in your profile first.</span>
</div> </div>
</div> </div>

View File

@@ -32,6 +32,21 @@
</div> </div>
</div> </div>
<div class="control-group">
<label class="control-label" for="password">API key</label>
<div class="controls horizontal-align">
<span ng-show="user.apiKey">{{user.apiKey}}</span>
<span ng-show="!user.apiKey">Not generated yet</span>
</div>
</div>
<div class="control-group">
<label class="control-label" for="password">Generate new API key</label>
<div class="controls">
<input type="checkbox" name="newApiKey" id="newApiKey" ng-model="newApiKey">
<span class="help-block">Changing password will generate a new API key</span>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" ng-click="cancel()">Cancel</button> <button type="button" class="btn" ng-click="cancel()">Cancel</button>