Решил реализовать на разрабатываемом портале авторизацию (регистрацию) и идентификацию пользователей с помощью инструмента разработчика социальных сетей (Social Networks REST API) – тематика далеко не новаторская, активно используется и очень удобная в использовании. Как бы перечислять все удобства и преимущества использования на своих сайтах подобного функционала не буду, но замечу, что меня очень устраивает не запоминать пароли для каждого сайта (пусть даже если у меня пара-тройка стандартно используемых), не участвовать в утомительных регистрациях с пересылками писем и подтверждениями, а также лишний раз не сталкиваться с каптчами.
Функционал данных API достаточно примитивный, технология несложная, а реализация достаточно однотипная и простая. Но когда ознакомляешься с технологией, документации и примеров API той или иной социальной сети недостаточно. Кроме этого, как написано в теме, используемый язык – Java, что автоматически уменьшает количество полезной информации. Да и в рунете описаний не так много. Можно идти по пути наименьшего сопротивления и использовать сторонние RESTful продукты, но а) это не дает полного понимания процесса; б) снижает коммутационные свойства требуемого процесса; в) зачастую исследование стороннего продукта может оказаться сложнее разработки своей реализации. Хотя удобство использования такого стороннего продукта может в разы облегчить разработку. Однако лично я в данном обзоре поставил акцент именно на том, чтобы максимально контролировать все процессы, даже в ущерб универсальности (мы «прикручиваем» конкретный функционал к конкретному сайту, и только единицы делают из этого универсальный продукт «на все случаи жизни»). Кроме этого, меня интересует не только реализовать авторизацию пользователей, но и внедрить в систему безопасности проекта, обеспечиваемую фреймворком Spring Security 3.
Используемый набор платформ и инструментария: Spring Core 3.1, Spring MVC 3.1, Spring Security 3.1, Hibernate 4.1. Проект внедрения забугорный, поэтому набор внедряемых соцсетей стандартный «для них» – Facebook, Twitter, Google+, LinkedIn.
Хочу отметить, что в пакете Spring имеется готовый проект «из коробки» – Spring Social (на сегодня релиз 1.0.2), который замечательно инкапсулируется в Spring-каркас продукта и рассчитан на использование с другими продуктами Spring. Наверняка это было бы профессиональное решение, но наша задача – все контролировать и сделать процесс максимально прозрачным для понимания. Да и с самим Social не все так гладко.
1. Модель.
Я пошел по несколько рискованному и противоречивому пути, объединив в объекте пользователя и POJO, и UserDetails, и Entity. С точки зрения подхода к программированию это неправильно, но а) очень удобно; и б) экономит создание нескольких слоев, избавляя нас делать отдельно POJO+Entity, отдельно UserDetails и отдельно DTO, что фактически дублирует содержимое.
Предложенная схема построения модели выглядит следующим образом:
Два слоя (AuthUser и DataUser) я выделил, чтобы не мешать между собой логику авторизации с бизнес-логикой проекта: и посетитель, и администратор, и кто бы ни был еще – авторизуются одинаково, но имеют свой набор свойств. К примеру, у меня в проекте есть Jobseekers и Employers, заходят на сайт одинаково, но имеют совершенно разную структуру модели.
Что касается разделения структуры внутри слоев, то это очевидно – набор получаемых полей из Facebook, Twitter и т.д., и особенно при стандартной авторизации, настолько разный, что создавать одну жутко растянутую структуру для всего просто глупо и с точки зрения построения БД – избыточно. А что касается масштабируемости, то при добавлении нового провайдера услуги работа с такой структурой была бы крайне неудобной.
Листинги некоторых из перечисленных объектов, а также используемые классы enum.
AuthUser.java:
@ Entity
@ Table(name = "auth_user")
@ Inheritance(strategy = InheritanceType.JOINED)
public class AuthUser implements Serializable, UserDetails {
@ Id
@ Column(name = "id")
@ GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@ Column(name = "identification_name", length = 64, nullable = false)
private String identificationName;
@ Enumerated(EnumType.STRING)
@ Column(name = "type", nullable = false)
private AuthorityType type;
@ Column(name = "binary_authorities", nullable = false)
private Long binaryAuthorities;
@ Column(name = "enabled", nullable = false, columnDefinition = "tinyint")
private Boolean enabled;
@ Transient
private Set<Authority> authorities;
@ OneToOne(fetch = FetchType.LAZY, orphanRemoval = true)
@ Cascade({CascadeType.ALL})
@ JoinColumn(name="user_id")
private User user;
@ Override
public Collection<? extends GrantedAuthority> getAuthorities() {
authorities = EnumSet.noneOf(Authority.class);
for (Authority authority : Authority.values())
if ((binaryAuthorities & (1 << authority.ordinal())) != 0)
authorities.add(authority);
return authorities;
}
public void setAuthority(Set<Authority> authorities) {
binaryAuthorities = 0L;
for (Authority authority : authorities)
binaryAuthorities |= 1 << authority.ordinal();
}
@ Override
public String getPassword() {
return type.name();
}
@ Override
public String getUsername() {
return identificationName;
}
@ Override
public boolean isAccountNonExpired() {
return true;
}
@ Override
public boolean isAccountNonLocked() {
return true;
}
@ Override
public boolean isCredentialsNonExpired() {
return true;
}
//getters/setters
}
AuthorityType.java:
public enum AuthorityType implements Serializable {
SIMPLE, FACEBOOK, TWITTER, GOOGLE, LINKEDIN;
}
Authority.java:
public enum Authority implements GrantedAuthority {
NEW_CUSTOMER, CUSTOMER, ADMINISTRATOR;
@ Override
public String getAuthority() {
return toString();
}
}
FacebookAuthUser.java:
@ Entity
@ Table(name = "facebook_auth_user")
public class FacebookAuthUser extends AuthUser {
@ Column(name = "first_name", length = 32)
private String firstName;
@ Column(name = "last_name", length = 32)
private String lastName;
@ Column(name = "email", length = 64)
private String email;
@ Column(name = "token", length = 128)
private String token;
//any number of available properties
//getters/setters
}
TwitterAuthUser.java:
@ Entity
@ Table(name = "twitter_auth_user")
public class TwitterAuthUser extends AuthUser {
@ Column(name = "screen_name", length = 64)
private String screenName;
@ Column(name = "oauth_token", length = 80)
private String oauthToken;
@ Column(name = "oauth_token_secret", length = 80)
private String oauthTokenSecret;
//any number of available properties
//getters/setters
}
SimpleAuthUser.java:
@ Entity
@ Table(name = "simple_auth_user")
public class SimpleAuthUser extends AuthUser {
@ Column(name = "password", length = 40, nullable = false)
private String password;
@ Column(name = "uuid", length = 36, nullable = false)
private String uuid;
@ Override
public String getPassword() {
return password;
}
//getters/setters
}
Как видно, без мало-мальской «химии» не обошлось:
- Делать еще одну структуру (таблицу) для хранения ролей пользователей не хочется, а лишать себя возможности наделять пользователя несколькими ролями, поменяв набор ролей на одну – глупо. Поэтому в базе я храню бинарное представление набора ролей, а Spring Security скармливаю набор. Главное не забыть при считывании из базы сделать трансформацию. Идеологически механизм перевода неправильно держать в POJO, надо оставить его в контроллере или DAO, но будем считать это издержками публикации.
- Поле type (enum AuthorityType) – оно не несет на себе особой необходимости, скорее для визуализации данных в базе, плюс в представлении проще пользоваться user.getType(), чем исследовать принадлежность одному из классов — user instanseof TwitterAuthUser, хотя это совершенно непринципиально.
- Интерфейс UserDetails требует имплементировать ряд методов. В частности, identificationName (и getUsername()) – поле, где хранятся идентификаторы: для Facebook это FacebookID, для Twitter – TwitterID, для стандартной авторизации – ник или емейл. Метод getPassword() в моем случае возвращает тот самый type, далее он будет использоваться Spring Security для формирования хеша для куков в механизме RememberMe. Для повышения секъюрности проекта в каждом из классов можно переопределить этот метод, назначив ему действительно секъюрные данные, как это я сделал в классе SimpleAuthUser. Для других это могут быть токены или secret токены. Что дальше делать с методами и потенциально соответствующими им свойствами isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired – решать по мере необходимости использовать такие данные, в данном обзоре я их не использую, заглушив return true.
Как видно на схеме и в коде, я решил использовать ленивую зависимость между объектами разных слоев, даже хотя они соотносятся как «один-к-одному». Целей две: 1) AuthUser часто дергается фреймворком в контроллере и представлении, и нет большого желания тащить везде за собой зависимую структуру, тем более, что она может быть весьма расширенной и массивной (у меня в проекте у таблиц Jobseeker только EAGER зависимостей штук 5-6, не считая LAZY – это и телефоны, и адрес, и профессии, и прочие), поэтому, на мой взгляд, перестраховка не помешает. 2) надо не забывать, что эти слои относятся к разным слоям логики: AuthUser дергается фреймворком Spring Security, а параллельно могут происходить изменения в DataUser, а заниматься постоянным отслеживанием и обновлением не хочется. Соглашусь, что данное решение спорное, и совершенно не претендует на окончательное. Возможно, связывать надо наоборот, тем самым уходят перечисленные проблемы, а из бизнес-логики всегда можно будет дернуть авторизационный бин. Это остается на усмотрение разработчиков.
Что касается класса DataUser и зависимых, то это простые POJO-классы, непосредственно DataUser содержит общие для всех свойства (скажем, id, firstName, lastName, email, location), а остальные его расширяют, добавляя специфические для себя свойства (приводить листинги нецелесообразно).
2. Контроллер.
В принципе, в терминологии authentication и authorization особой разницы нет – авторизация она и есть авторизация, причем, разные сетевые провайдеры склоняют эти термины на свой лад. Однако я в своем отчете четко различаю 2 понятия – регистрация и непосредственно авторизация или вход (обе основаны на авторизации у социально-сетевого провайдера). Скажем, при участии в форуме, или подаче комментария надо просто авторизоваться – будь то в первый вход, или в сотый. У себя разделением на регистрацию и простую авторизацию я преследую необходимостью создания пользовательской модели при подаче заявки на регистрацию. И хотя это можно было бы реализовать проще – при входе проверяем – есть или нет такой человек – и создаем пользовательскую структуру в случае первого входа. Но а) существует стандартная регистрация и логично делать визуальное разделение «тут одно, а тут другое» (пресловутый usability); б) как ни обидно, но API социальных сетей не единодушны в предоставлении информации о своих клиентах – скажем, Facebook API предоставляет емейл, имя, фамилию, пол, локацию; Twitter API – дает screen_name, который может и не быть «имя фамилия», не дает емейл (у них позиция четко разграничивать реал и виртуал); Google+ API предоставляет имя, фамилию, емейл, но ничего о локации; LinkedIn API – имя, фамилию, пол, локацию, но не отдает емейл. Так как мой проект очень тесно завязан с личными данными посетителя (проект для рекрутской компании), вместе с регистрацией я указываю на необходимость заполнять некоторые поля (изначально кроме пользователей Facebook всем надо было хоть что-то указать, сейчас упрощено и такая необходимость есть только у пользователей Twitter, хотя не исключаю полный отказ, а заполнение полей при возникшей необходимости – скажем, при попытке пройти в «зону», где такая информация будет уже просто необходимой). Поэтому моя реализация несколько раздута, хотя это только больше помогает разобраться в механизме авторизации.
Хочу напомнить, что для работы (или тестирования) необходимо создать свое приложение в каждой из социальных сетей, и использовать его настройки для работы. Для Facebook это developers.facebook.com/apps, для Twitter – dev.twitter.com/apps, для Google+ – code.google.com/apis/console, для LinkedIn – www.linkedin.com/secure/developer. При создании приложения важны 3 параметра, которые есть у каждого провайдера – это ключ (или API key, Consumer key, Client ID), секретный ключ (App Secret, Consumer secret, Client secret, Secret key) и адрес редиректа (говорят, до недавнего времени у кого-то из провайдеров не работал редирект на localhost, но сегодня проверено – все работают с адресом типа http://localhost:8080/myproject). Там же можно настроить другие параметры, в том числе и лого приложения, правда, LinkedIn требует, чтобы ссылка на картинку была SSL (непонятное пожелание).
Facebook и Google+ уже давно пользуются новым протоколом OAuth 2, Twitter и LinkedIn пока остаются на более старом протоколе OAuth (Google+ тоже поддерживал первую версию OAuth до 20 апреля 2012 года). На мое усмотрение (хотя не представляю, чтобы было другое мнение) работать с OAuth 2 несоизмеримо проще и удобнее, правда, несмотря на то, что он достаточно популярный, он по-прежнему не утвержден как стандарт. Принцип работы достаточно примитивный (наиболее популярная схема):
Итак, пользователь на веб-страничке кликает на одну из кнопок регистрации:
(причем, никакого «лишнего» функционала на странице я не оставляю, только кнопка с адресом типа www.myproject.com/registration/facebook и т.д.), запрос попадает в контроллер (для Facebook):
@ RequestMapping(value = "/registrate/facebook", method = RequestMethod.POST)
public ModelAndView facebookRegistration() throws Exception {
return new ModelAndView(new RedirectView(FACEBOOK_URL + "?client_id=" + FACEBOOK_API_KEY +
+ "&redirect_uri=" + FACEBOOK_URL_CALLBACK_REGISTRATION +
+ "&scope=email,user_location&state=registration", true, true, true));
}
Параметры для scope можно найти по адресу developers.facebook.com/docs/authentication/permissions (для Twitter — dev.twitter.com/docs/platform-objects/users, Google+ — developers.google.com/accounts/docs/OAuth2Login#userinfocall, LinkedIn — developer.linkedin.com/documents/profile-fields), я тут привел только пару. Домен redirect_uri должен совпадать с зарегистрированным адресом приложения. state – «свободный» параметр, я его использую как семафор для дальнейших действий – registration, signin, autosignin.
Далее пользователь «редиректится» на родную страницу Facebook авторизации, где он разрешит приложению воспользоваться его данными, причем, если разрешения выходят за рамки базовых, то в окне авторизации они будут перечислены.
После авторизации наш контроллер с mapping FACEBOOK_IRL_CALLBACK_REGISTRATION получает вызов (при любом решении клиента – авторизоваться, отменить, вернуться). Spring MVC позволяет нам по меппингу сразу фильтровать запросы (в данном случае приведен меппинг моего проекта):
@ RequestMapping(value = "/callback/facebook", method = RequestMethod.GET)
public class FacebookController extends ExternalController implements Constants {
@ RequestMapping(value = "/registration", params = "code")
public ModelAndView registrationAccessCode(@ RequestParam("code") String code, HttpServletRequest request) throws Exception {
String authRequest = Utils.sendHttpRequest("GET", FACEBOOK_URL_ACCESS_TOKEN, new String[]{"client_id", "redirect_uri", "client_secret", "code"}, new String[]{FACEBOOK_API_KEY, FACEBOOK_URL_CALLBACK_REGISTRATION, FACEBOOK_API_SECRET, code});
String token = Utils.parseURLQuery(authRequest).get("access_token");
String tokenRequest = Utils.sendHttpRequest("GET", FACEBOOK_URL_ME, new String[]{"access_token"}, new String[]{token})
Map<String, Json> userInfoResponse = Json.read(tokenRequest).asJsonMap();
String email = userInfoResponse.get("email").asString().toLowerCase();
String id = userInfoResponse.get("id").asString();
//verifying ... is new? is email in DB?
//creating objects
Customer customer = new Customer();
customer.setEmail(email);
//...
customerer = (Customerer) userDAO.put(customer);
FacebookAuthUser user = new FacebookAuthUser();
user.setFirstName(firstName);
//...
user.setIdentificationName(id);
user.setToken(token);
user.setType(AuthenticationType.FACEBOOK);
user.setEnabled(true);
user.setAuthority(EnumSet.of(Authority.CUSTOMER));
user.setUser(customer);
authenticationDAO.put(user);
return new ModelAndView(new RedirectView("/registrate.complete", true, true, false));
}
@ RequestMapping(value = "/registration", params = "error_reason")
public ModelAndView registrationError(@ RequestParam("error_description") String errorDescription, HttpServletRequest request, HttpServletResponse response) {
//return client to registration page with errorDescription
return new ModelAndView(new RedirectView("/registrate", true, true, false));
}
//will signin and signinError
}
Для удобства и унитарного использования пара статических методов класса Utils, использовавшихся в данном листинге:
public static String sendHttpRequest(String methodName, String url, String[] names, String[] values) throws HttpException, IOException {
if (names.length != values.length) return null;
if (!methodName.equalsIgnoreCase("GET") && !methodName.equalsIgnoreCase("POST")) return null;
HttpMethod method;
if (methodName.equalsIgnoreCase("GET")) {
String[] parameters = new String[names.length];
for (int i = 0; i < names.length; i++)
parameters[i] = names[i] + "=" + values[i];
method = new GetMethod(url + "?" + StringUtils.join(parameters, "&"));
} else {
method = new PostMethod(url);
for (int i = 0; i < names.length; i++)
((PostMethod) method).addParameter(names[i], values[i]);
method.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
}
new HttpClient().executeMethod(method);
return getStringFromStream(method.getResponseBodyAsStream());
}
public static Map<String, String> parseURLQuery(String query) {
Map<String, String> result = new HashMap<String,String>();
String params[] = query.split("&");
for (String param : params) {
String temp[] = param.split("=");
try {
result.put(temp[0], URLDecoder.decode(temp[1], "UTF-8"));
} catch (UnsupportedEncodingException exception) {
exception.printStackTrace();
}
}
return result;
}
Константы:
final public static String FACEBOOK_API_KEY = "XXXXXXXXXXXXXXXX";
final public static String FACEBOOK_API_SECRET = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
final public static String FACEBOOK_URL = "https://www.facebook.com/dialog/oauth";
final public static String FACEBOOK_URL_ACCESS_TOKEN = "https://graph.facebook.com/oauth/access_token";
final public static String FACEBOOK_URL_ME = "https://graph.facebook.com/me";
final public static String FACEBOOK_URL_CALLBACK_REGISTRATION = SITE_ADDRESS + "/callback/facebook/registration";
final public static String FACEBOOK_URL_CALLBACK_SIGNIN = SITE_ADDRESS + "/callback/facebook/signin";
Библиотеками JSON каждый может пользоваться на свое усмотрение, я воспользовался библиотекой mjson (http://sharegov.blogspot.com/2011/06/json-library.html) – маленькая, удобная и без сериализаций.
Как видно, процесс простой и не должен вызывать особых вопросов. Хочу еще заметить, что Facebook выдает параметр location, значения из которого можно «подсунуть» Google Maps API (по адресу http://maps.googleapis.com/maps/api/geocode/json) и вытащить геолокацию в удобной форме (по стандартам Google Maps). Понятно, речь может идти только в том случае, если клиент в своем аккаунте на Facebook указал не только страну локации.
Регистрация в Google+ происходит аналогичным образом, с той только разницей, что callback URL в их системе должен полностью совпадать с тем, который указан в настройках приложения. Таким образом, все редиректы попадут только на один меппинг. Для разделения процессов удобно использовать возвращаемый параметр state:
@ RequestMapping(value = "/callback/google", method = RequestMethod.GET)
public class GoogleController extends ExternalController implements Constants {
@ RequestMapping(value = {"/", ""}, params = "code")
public ModelAndView googleProxy(@ RequestParam("code") String code, @ RequestParam("state") String state, HttpServletRequest request, HttpServletResponse response) throws Exception { ... }
@ RequestMapping(value = {"/", ""}, params = "error")
public ModelAndView googleErrorProxy(<hh user=RequestParam>("error") String error, <hh user=RequestParam>("state") String state, HttpServletRequest request) throws Exception { ... }
}
Остальные действия, за исключением адресов и возвращаемых параметров, идентичны.
По-другому дело обстоит с авторизацией по протоколу OAuth (Twitter и LinkedIn). Я прошел всю цепочку авторизации, но это очень неудобно из-за формирования запроса с токенами – их надо особым образом «склеить» запаковать base64, добавлять параметры со временем и прочие манипуляции. И что самое удивительное – разделы для разработчиков этих социальных сетей не отображают эти процессы. Хотя это стандарт, поэтому расчет идет на стандартный подход. В любом случае, авторизация подобным образом, реализованная «вручную», не представляет интереса для разработки своего приложения. Я советую воспользоваться сторонними свободными библиотеками, которые облегчают эту задачу. Для примера, есть библиотека специально для Twitter – twitter4j.jar. Я воспользовался библиотекой scribe-java (http://github.com/fernandezpablo85/scribe-java), которая распространяется на правах MIT лицензии. В пакете есть работа с Digg API, Facebook API, Flickr API, Freelancer API, Google API, LinkedIn API, Skyrock API, Tumblr API, Twitter API, Vkontakte API, Yahoo API и еще десятка 2 других.
Процесс регистрации для Twitter с использованием библиотеки scribe будет выглядеть следующим образом. Контроллер запроса клиента на авторизацию со страницы регистрации:
@ RequestMapping(value = "/registrate/twitter", params = "action", method = RequestMethod.POST)
public ModelAndView twitterRegistrationJobseeker(HttpServletRequest request) throws Exception {
OAuthService service = new ServiceBuilder().provider(TwitterApi.class)
.apiKey(TWITTER_CONSUMER_KEY).apiSecret(TWITTER_CONSUMER_SECRET)
.callback(TWITTER_URL_CALLBACK_REGISTRATION).build();
Token requestToken = service.getRequestToken();
request.getSession().setAttribute("twitter", service);
request.getSession().setAttribute("request_token", requestToken);
return new ModelAndView(new RedirectView(service.getAuthorizationUrl(requestToken), true, true, true));
}
Callback контроллер Twitter:
@ RequestMapping(value = "/callback/twitter", method = RequestMethod.GET)
public class TwitterController extends ExternalController implements Constants {
@ RequestMapping(value = "/registration", params = "oauth_verifier")
public ModelAndView registrationAccessCode(@ RequestParam("oauth_verifier") String verifier, HttpServletRequest request, HttpServletResponse response) throws Exception {
OAuthService service = (OAuthService) request.getSession().getAttribute("twitter");
Token accessToken = service.getAccessToken((Token) request.getSession().getAttribute("request_token"), new Verifier(verifier));
OAuthRequest oauthRequest = new OAuthRequest(Verb.GET, TWITTER_URL_CREDENTIALS);
service.signRequest(accessToken, oauthRequest);
Map<String, Json> userInfoResponse = Json.read(oauthRequest.send().getBody()).asJsonMap();
String twitterId = userInfoResponse.get("id").asString();
//verifying ...
Customer customer = new Customer();
customer.setFirstName((String) request.getSession().getAttribute("pageValueFirstName"));
//...
customer = (Customer) userDAO.put(customer);
TwitterAuthUser user = new TwitterAuthUser();
user.setAuthority(EnumSet.of(Authority.CUSTOMER));
user.setIdentificationName(twitterId);
//...
user.setOauthToken(accessToken.getToken());
user.setOauthTokenSecret(accessToken.getSecret());
user.setType(AuthenticationType.TWITTER);
user.setUser(customer);
authenticationDAO.put(user);
return new ModelAndView(new RedirectView("/registrate.complete", true, true, false));
}
@ RequestMapping(value = "/registration", params = "denied")
public ModelAndView registrationError(HttpServletRequest request) {
//response does not contain the error text
return new ModelAndView(new RedirectView("/registrate", true, true, false));
}
//will signin and signinError
}
Опять-таки, все очень просто и доступно. Регистрация через LinkedIn API производится совершенно аналогичным образом.
Последнее – регистрация стандартным способом. Стандартный – на то он и стандартный, код приводить не буду, уточню только, что в результате мы создаем объект типа SimpleAuthUser, наследуемый от AuthUser:
SimpleAuthUser user = new SimpleAuthUser();
user.setAuthority(EnumSet.of(Authority.NEW_CUSTOMER));
user.setEnabled(false);
user.setIdentificationName(email);
user.setPassword(passwordEncoder.encodePassword(password, email));
user.setType(AuthenticationType.SIMPLE);
user.setUser(customer);
user.setUuid(uuid);
authenticationDAO.put(user);
Именно для этого случая понадобился authority NEW_CUSTOMER – зарегистрированный пользователь нуждается в подтверждении регистрации (стандартная практика), а потому а) имеет другую роль; б) не допускается к авторизации Spring Security (enabled = false).
Авторизация на сайте
Простенький спринговский application-context-security.xml:
<security:global-method-security secured-annotations="enabled" jsr250-annotations="enabled" pre-post-annotations="enabled" proxy-target-class="true"/>
<security:http auto-config="true" use-expressions="true">
<security:intercept-url pattern="/**" access="permitAll"/>
<security:form-login login-page="/signin"/>
<security:logout invalidate-session="true" logout-success-url="/" logout-url="/signout"/>
<security:remember-me services-ref="rememberMeService" key="someRememberMeKey"/>
</security:http>
<security:authentication-manager alias="authenticationManager">
<security:authentication-provider ref="authenticationProvider"/>
</security:authentication-manager>
<bean id="authenticationProvider" class="myproject.security.CustomAuthenticationProvider"/>
<bean id="rememberMeService" class="myproject.security.RememberMeService">
<property name="key" value="someRememberMeKey"/>
<property name="userDetailsService" ref="userDetailsService"/>
</bean>
<bean id="userDetailsService" class="myproject.security.CustomUserDetailsManager"/>
<bean id="passwordEncoder" class="org.springframework.security.authentication.encoding.ShaPasswordEncoder"/>
CustomUserDetailsManager.java:
public class CustomUserDetailsManager implements UserDetailsService {
@ Resource private AuthenticationDAO authenticationDAO;
@ Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return authenticationDAO.findAuthUser(username);
}
}
CustomUserAuthentication.java:
public class CustomUserAuthentication implements Authentication {
private String name;
private Object details;
private UserDetails user;
private boolean authenticated;
private Collection<? extends GrantedAuthority> authorities;
public CustomUserAuthentication(UserDetails user, Object details) {
this.name = user.getUsername();
this.details = details;
this.user = user;
this.authorities = user.getAuthorities();
authenticated = true;
}
@ Override
public String getName() {
return name;
}
@ Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@ Override
public Object getCredentials() {
return user.getPassword();
}
@ Override
public Object getDetails() {
return details;
}
@ Override
public Object getPrincipal() {
return user;
}
@ Override
public boolean isAuthenticated() {
return authenticated;
}
@ Override
public void setAuthenticated(boolean authenticated) throws IllegalArgumentException {
this.authenticated = authenticated;
}
}
CustomAuthenticationProvider.java
(класс совершенно бестолковый, но Spring Security необходимо скормить наследника интерфейса AuthenticationProvider, но ближайший по смыслу PreAuthenticatedAuthenticationProvider не подходит):
public class CustomAuthenticationProvider implements AuthenticationProvider {
@ Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//тут могут быть доп. проверки
return authentication;
}
@ Override
public boolean supports(Class<?> authentication) {
return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication);
}
public Authentication trust(UserDetails user) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Authentication trustedAuthentication = new CustomUserAuthentication(user, authentication.getDetails());
authentication = authenticate(trustedAuthentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication;
}
}
И, пожалуй, самым «узким» местом организации секъюрити является реализация механизма RememberMe. В принципе, все уже организовано так, что работа сервиса RememberMe полностью соответствует реализации TokenBasedRememberMeServices, с одним уточнением: все данные для автоматической авторизации клиента находятся у нас в базе, однако может существовать ситуация, когда пользователь зарегистрировался на сайте, а затем удалил учетную запись на используемой социальной сети. И получается коллизия – пользователя мы авторизуем, но фактически его нет, то есть главные принципы авторизации через сторонние сервисы нарушен. То есть при срабатывании механизма RememberMe мы должны проверять автоматически входящего клиента. В API каждого сетевого провайдера есть такие механизмы, но мы должны «вклиниться» в работу спринговского RememberMe, чтобы на нужном этапе произвести проверку. К сожалению, расширить какой-то класс не получится (у AbstractRememberMeServices нужный нам метод установлен как final), поэтому надо переопределять полностью класс. Мой способ более громоздок, я пошел с конца, а банальная человеческая лень не разрешает переделывать на более простой вариант. Я полностью переопределил класс AbstractRememberMeServices с включением кода класса TokenBasedRememberMeServices, добавив пару строк в метод public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) – после проверок значений в методе, но перед непосредственной авторизацией вставил вызов метода проверки «реальности» клиента:
Class<? extends ExternalController> controller = externalControllers.get(user.getPassword());
if (controller != null && !controller.newInstance().checkAccount(user)) return null;
А ранее, в конструкторе определяю статический список:
private Map<String, Class<? extends ExternalController>> externalControllers;
public CustomRememberMeService() {
externalControllers = new HashMap<String, Class<? extends ExternalController>>(){{
put(AuthenticationType.FACEBOOK.name(), FacebookController.class);
put(AuthenticationType.TWITTER.name(), TwitterController.class);
put(AuthenticationType.GOOGLE.name(), GoogleController.class);
put(AuthenticationType.LINKEDIN.name(), LinkedinController.class);
}};
}
(данное внедрение кода никак не повлияет на работу RememberMe для стандартной авторизации).
А более простой способ подразумевает замену фильтра REMEMBER_ME_FILTER на свой, где надо поместить аналогичный код после вызова вышеуказанного метода autoLogin, но до непосредственной авторизации. Он менее затратный по коду и более логичный для понимания, правда, требует вмешательства в конфиг. По какому пути пойти – каждый решит сам, но второй, на мой взгляд, идеологически более «чистый».
Еще надо уточнить по поводу класса ExternalController и вызова checkAccount(user). Все мои callback-контроллеры расширяют класс ExternalController:
public abstract class ExternalController {
public abstract boolean checkAccount(UserDetails user) throws Exception;
}
и каждый контроллер переопределяет этот единственный метод. К примеру, для Facebook это:
public boolean сheckAccount(UserDetails user) throws Exception {
FacebookAuthUser facebookUser = (FacebookAuthUser) user;
String authRequest = Utils.sendHttpRequest("GET", FACEBOOK_URL_ME, new String[]{"access_token"}, new String[]{facebookUser.getToken()});
Map<String, Json> tokenInfoResponse = Json.read(authRequest).asJsonMap();
return tokenInfoResponse.get("error") == null && tokenInfoResponse.get("id").asString().equalsIgnoreCase(facebookUser.getIdentificationName());
}
а для Twitter:
public boolean checkAccount(UserDetails user) throws Exception {
TwitterAuthUser twitterUser = (TwitterAuthUser) user;
OAuthService service = new ServiceBuilder().provider(TwitterApi.class).apiKey(TWITTER_CONSUMER_KEY).apiSecret(TWITTER_CONSUMER_SECRET).build();
OAuthRequest oauthRequest = new OAuthRequest(Verb.GET, TWITTER_URL_CREDENTIALS);
service.signRequest(new Token(twitterUser.getOauthToken(), twitterUser.getOauthTokenSecret()), oauthRequest);
String response = oauthRequest.send().getBody();
Map<String, Json> info = Json.read(request).asJsonMap();
return info.get("id").asString().equalsIgnoreCase(twitterUser.getIdentificationName());
}
и т.д.
Непосредственно сама авторизация на сайте (login, sign in) очень напоминает регистрацию. Пользователь заходит на страничку, нажимает «Войти» и его так же перебрасывает на авторизацию:
Единственное, я передаю на сервер параметр – «signin» или «autosignin», в зависимости от того, нажат ли флажок «входить автоматически». Далее все происходит по аналогичному с регистрацией сценарию, только меняется параметр, callback URL и убираю все scope или permission – нам надо получить только ID клиента и его токены. После соответствующих проверок в методах контроллера я советую перезаписывать токены в БД. И хотя, к примеру, Facebook за время моих тестов не поменял токен клиента, а Google+ делает это каждый раз. Не знаю, как часто происходит «смена», поэтому перезапись осуществляю после каждого получения access_token (фактически при каждой неавтоматической авторизации у провайдера).
И самый важный момент – непосредственная авторизация пользователя в Spring Security (после проверок, конечно, на соответствие и получения прав от API провайдера), на примере контроллера Facebook:
@ RequestMapping(value = "/signin", params = "code")
public ModelAndView signInAccessCode(@ RequestParam("code") String code, @ RequestParam("state") String state, HttpServletRequest request, HttpServletResponse response) throws Exception {
String accessRequest = Utils.sendHttpRequest("GET", FACEBOOK_URL_ACCESS_TOKEN, new String[]{"client_id", "redirect_uri", "client_secret", "code"}, new String[]{FACEBOOK_API_KEY, FACEBOOK_URL_CALLBACK_SIGNIN, FACEBOOK_API_SECRET, code});
String token = Utils.parseURLQuery(accessRequest).get("access_token");
Map<String, Json> userInfoResponse = Json.read(Utils.sendHttpRequest("GET", FACEBOOK_URL_ME, new String[]{"access_token"}, new String[]{token})).asJsonMap();
FacebookAuthUser user = (FacebookAuthUser) authenticationDAO.findAuthUser(userInfoResponse.get("id").asString(), AuthenticationType.FACEBOOK);
if (user == null) {
//что-то пошло не так ...
return new ModelAndView(new RedirectView("/signin", true, true, false));
} else {
if (!token.equals(user.getToken())) {
user.setToken(token);
user = (FacebookAuthUser) authenticationDAO.put(user);
}
Authentication authentication = customAuthenticationProvider.trust(user);
if (state.equalsIgnoreCase("autosignin")) customRememberMeService.onLoginSuccess(request, response, authentication);
else customRememberMeService.logout(request, response, authentication); //очистить куки RememberMe
return new ModelAndView(new RedirectView("/signin.complete", true, true, false));
}
}
Теперь при установленной галочке автовхода клиент будет автоматически залогинен. Соответственно, если галочки нет, вызов метода logout у сервиса RememberMe сотрет куки (больше он ничего не делает). Кстати, переход по ссылке "/logout" снимает авторизацию и чистит куки автоматически. Это предусмотрено соответствующей строкой в конфиге Spring Security выше.
Использование данного метода можно «прикрутить» и для стандартной авторизации: после прохождения проверок (нахождения пользователя в таблице, сверки хеша пароля и т.д.) вручную авторизуем его:
Authentication authentication = customAuthenticationProvider.trust(user);
if (autosignin) customRememberMeService.onLoginSuccess(request, response, authentication);
else customRememberMeService.logout(request, response, authentication);
Разницы в использовании никакой нет. Единственное, что различает, – это то, что при срабатывании механизма RememberMe не будет никаких посторонних проверок, фактически, полностью совпадет с работой сервиса TokenBasedRememberMeServices.
Далее использование авторизации аналогично использованию обычных ролей Spring Security, с той только разницей, что нельзя пользоваться аннотацией @Secured(«CUSTOM_ROLE»), он рассчитан на стандартные роли (хотя вроде есть механизм их переопределения, в который я не вдавался). Но у Spring Security есть другой механизм – использование тех же аннотаций @PreAuthorize, @PostFilter: @PreAuthorize(«hasRole('ADMINISTRATOR')»), @PreAuthorize(«hasRole({'CUSTOMER', 'ADMINISTRATOR'})»). Надо только в конфиге Spring Security указать это в параметре security:global-method-security.
Аналогичным образом можно пользоваться преимуществами и возможностями Spring Security в представлении (в JSP). К примеру:
<%@ taglib prefix="core" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
...
<sec:authorize access="isAuthenticated()">
<div id="userBox">
<span>Welcome, <sec:authentication property="principal.user.firstName"/>!</span>
</div>
</sec:authorize>
Подобные конструкции позволяют не передавать модель из контроллера в представление, а оставить механизм изъятия модели на представление (оно само обратится за моделью в ДАО).
Также можно использовать jsp-скриплеты на jsp-страницах (хотя у использования скриплетов появляется масса противников, в основном из-за позиции «бобу – богово, кесарю — кесарево», программист занимается программированием, а верстальщик и/или дизайнер – оформлением; но этот вопрос спорный, я лично не являюсь сторонником ни одной, ни другой концепции – да, некрасиво, да, иногда очень удобно):
<%@ page import="org.springframework.security.core.context.SecurityContextHolder" %>
<%@ page import="myproject.auth.AuthUser" %>
<% Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
AuthUser authUser = null;
if (!(principal instanceof String)) authUser = (AuthUser) principal; %>
...
<input type="text" <%= authUser == null ? "" : "disabled="disabled"" %> name="your_name" value="<%= authUser == null ? "" : authUser.getUser().getFirstName()%>"/>
...
<% if (authUser == null) { %>
<div id="recaptcha_widget">
<div id="recaptchaContainer">
...
</div>
</div>
<% } %>
Приведенный код страницы только отражает возможности использования контекста секъюрити, но не претендует на какую-то осмысленность логики страницы.
Хочу акцентировать внимание на «узкое» место, вызванное ленивой зависимостью между объектом аутенфикации и его параметрами (объекта-принципала): без доработок оба куска кода страниц при открытии вызовут Runtime Exception, так как поле user (вызов метода getUser()) будет содержать по умолчанию объект со всеми полями, заполненными как null. Использование паттерна OpenSessionInView без дополнительной подгрузки зависимого объекта в данном случае не поможет, так как сессии HTTP тут разные. Поэтому надо либо подгружать зависимый объект сразу при загрузке, но это противоречит тому подходу, из-за которого и была назначена ленивая связь – объект будет загружен и изменение зависимого объекта не приведет к обновлению загруженного, в этом случае проще установить EAGER-связь. Я это решил в authenticationDAO заменой обычно используемого sessionFactory.getCurrentSession() на открытие новой сессии: SessionFactoryUtils.openSession(sessionFactory). Возможно, это не самое экономное в плане памяти решение, но я пока не задавался этим вопросом и не углублялся в данную тематику. Думаю, что установив проверку на наличие текущей сессии, можно отказаться от фильтра или интерцептора OpenSessionInView, фактически заменив его работу.
Текста получилось более чем нужно, наверняка есть спорные или даже ошибочные моменты, но я постарался отразить решение тех трудностей, с которыми столкнулся сам при реализации задуманного механизма.
Автор: IDVsbruck
Очень благодарен!