Ostrzegam, ze post jest długi
Wczoraj postawiłem sobie cel stworzenia elastycznego systemu uwierzytelniania opartego na rolach i chcialbym sie z wami podzielic tym, co stworzylem.
Jesli chodzi o RoR to dopiero zaczynam swoja przygode, dlatego prosze o wskazowki na temat tego co zmienic zeby system byl jeszcze bardziej ‘rails way’. Nie wiem tez czy moje rozwiazanie jest optymalne (moze nie nadaje sie do realnych zastosowan? :)), opiera sie glownie na wyrazeniach regularnych, ale slyszalem, ze w Ruby smigaja one calkiem szybko.
W koncu, moze ten post przyczyni sie do rozwiazania problemow innych osob?
Ok, przejdzmy do rzeczy. Chcialem miec dosc rozbudowany system uwierzytelniania uzytkownikow na podstawie rol. Zalozylem ze kazda akcja (ktora wymaga uwierzytelnienia) wymaga od uzytkownika posiadania roli w formie: ‘controller_name/action_name’ czyli np. akcja ‘delete_article’ ktora nalezy do kontrolera admin/content_controller.rb wymaga posiadania roli ‘admin/content/delete_article’. Zalozylem rowniez ze moga sie zdazyc jakies szczegolne przypadki, kiedy dana akcja wymaga roli w innym formacie, albo kilku rol, ale o tym pozniej.
Role nie naleza bezposrednio do uzytkownikow, ale do grup a uzytkownicy i grupy powiazane sa relacja wiele-do-wielu. Jest to duzo rozsadniejsze rozwiazanie niz duplikowac te same role dla wielu rekordow w tabeli users.
Teraz cos o dzialaniu systemu. Pierwsza sprawa to zaprojektowanie go tak, aby mozna bylo w latwy sposob okreslac to, ktore akcje uzytkownik nalezacy do danej grupy moze wykonywac, a ktore nie. Najprostrze rozwiazanie to wpisac kolejno wszystkie dozwolone role na liste grupy. Niestety rodzi to pewien problem: taka lista moglaby sie okazac bardzo dluga, na przyklad dla grupy o nazwie ‘Moderatorzy’ trzebaby wpisac wszystkie akcje pozwalajace na zarzadzanie zbiorem artykulow, komentarzy, tematow na forum i Bog wie czego jeszcze. To rozwiazanie uznalem za kiepskie, nikomu nie bedzie sie chcialo wpisywac dziesiatek rol, lepiej juz ustalic jakies na sztywno w kodzie.
Wymyslilem cos innego mianowicie wzorce rol - zamiast zmudnie dodawac kolejne role wpisujemy na liste wzorzec - jesli wymagana przez akcje rola pasuje do wzorca uzytkownik dostaje pozwolenie na jej wykonanie - wzorzec przechodzi przez szereg wyrazen regularnych, ktore sprawdzaja czy pasuje do listy wymaganych rol. Ponizej kilka przykladow uzycia wzorcow rol:
Standard: dostep do akcji edit z kontrolera admin/articles_controller oraz akcji edit z kontrolera admin/comments_controller, role musi dzielic przecinek:
admin/articles/edit, admin/comments/edit
jesli rola konczy sie znakiem / to daje dostep do wszystkich akcji danego kontrolera albo calego modulu:
admin/articles/, admin/comments/
Wykluczanie: role w bloku except() sa wykluczone z listy, w tym wypadku mamy dostep do wszystkich akcji kontrolera admin/comments_controller oraz do akcji kontrolera admin/articles_controller z wyjatkiem jego akcji delete_article
admin/articles/ except(delete_article), admin/comments/
Mozna tez tak: wszystko z modulu admin oprocz rol z articles/ i oprocz roli delete_comment z admin/comments_controller
admin/ except( articles/, comments/delete_comment )
Only: prawa tylko do rol w bloku only()
admin/articles/ only( edit_article, publish_article )
Ok, czas na konkret, oto kod tego systemu, bede wdzieczny za wszelkie uwagi:
W application.rb sa metody authorize - sprawdza czy user jest zalogowany oraz metoda require_roles - to wlasnie filtr uzywany do sprawdzania rol dla danej akcji. require_role sprawdza parametry z request.path_parameters w celu okreslenia jaka rola bedzie potrzeban do wywolania akcji - tutaj musze przyznac ze nie jestem do konca przekonany o tym czy to najlepsza metoda, jak myslicie ?
[code=ruby]#application.rb
def authorize
@user = session[:user]
unless @user
session[:redirect_to] = request.request_uri
flash[:notice] = ‘Musisz się zalogować.’
redirect_to_login
end
end
def require_roles( *roles )
authorize
if @user
if roles.empty?
#skladamy wymagana role z parametrow path_parameters
roles << request.path_parameters[:controller] + ‘/’ + request.path_parameters[:action]
end
unless @user.has_roles( roles ) || @user.has_roles( ‘admin’ )
flash[:notice] = ‘Nie masz uprawnień do wykononia tej akcji.’
redirect_to_login
end
end
end[/code]
filtr require_roles jest uniwersalny, jesli jakas akcja musi byc walidowana w inny niz domyslny sposob to w kontrolerze mozemy napisac cos w stylu:
[code=ruby]before_filter :require_roles, :except => :nasza_akcja
def nasza_akcja
require_roles :rola1, :rola2
#…
end[/code]
Fragment modelu user.rb:
[code=ruby]class User < ActiveRecord::Base
has_and_belongs_to_many :groups
validacje, metody autoryzujace itp…
def has_roles( required_roles )
matches = Array.new
# przeszukuj wszystkie grupy do ktorych nalezy pod katem wymaganych rol
self.groups.each { |group| matches = matches | group.has_roles( required_roles ) }
required_roles.each do |required_role|
unless matches.include?( required_role )
return false
end
end
return true
end
end[/code]
Najciekawsza rzecz: model group.rb
[code=ruby]class Group < ActiveRecord::Base
has_and_belongs_to_many :users
validates_presence_of :name
validates_uniqueness_of :name
def has_roles( required_roles )
matches = Array.new
# wstepne parsowanie
roles = self.parse_roles( self.roles )
#sprawdzamy czy wymagane role pasuja do wzorcow z listy
required_roles.each do |required_role|
roles.each do |role_pattern|
if self.role_matches( role_pattern, required_role )
#dodaj do tablicy dopasowanych
matches << required_role
end
end
end
return matches
end
sprawdza czy rola pasuje do wzorca
def role_matches( pattern, role )
if role == pattern
return true
# przypadek gry rola konczy sie '/' - mamy dostep do wszystkich akcji w tym module
elsif pattern =~ /\/$/
if role =~ /^#{pattern}/
return true
end
# przypadek z blokiem except()
elsif pattern =~ /\/\s*except\([\w\/,\s]*\)$/
#dzielimy na to co przed except oraz liste rol w bloku except
pattern_parts = pattern.split( /\s*except\(\s*/ )
# sprawdzamy czy pierwsza czesc patterny pasuje
if role =~ /^#{pattern_parts[0]}/
# zrobimy sobie liste rol z bloku except, pamietajac zeby wywalic
# jeszcze nawias zamykajacy z konca lancucha
except_roles = self.split_roles( pattern_parts[1].sub( /\s*\)/, '' ) )
except_roles.each do |except_role|
# odwrotnie niz w poprzednich przypadkach:
# tutaj jesli role pasuje do wzorca to znaczy ze nie mamy do niej praw ;)
if role == pattern_parts[0] + except_role ||
( role =~ /^#{pattern_parts[0] + except_role}/ && except_role.ends_with?('/') )
return false
end
end
return true
end
end
return false
end
#wstepna obrobka
def parse_roles( roles_string )
roles = Array.new
# jesli pojawia sie blok only() to od razu dodajemy wzorce dla elementow
# tego bloku do listy wzorcow
roles_string.gsub!(/[\w/]/\s\w*\sonly([\w/,\s])/) do |match|
pattern_parts = match.split( /\sonly(\s/ )
only_roles = self.split_roles( pattern_parts[1].sub( /\s*)/, ‘’ ) )
only_roles.each { |role| roles << pattern_parts[0] + role }
match = ‘’
# dodajemy role z blokiem except() w niemienionej postaci
# i pozniej usuwamy to wszystko z lancucha, aby role z listy
# except() nie braly udzialu w reszcie procesu
end
roles_string.gsub!(/[\w/]/\s\w*\sexcept([\w/,\s])/) do |match|
roles << match
match = ‘’
end
# to co zostalo dzielimy wzgledem przecinka - sa to wzorce rol bez zadnych blokow
roles | self.split_roles( roles_string )
end
def split_roles( roles_string )
roles_string.split( /\s*,\s*/ )
end[/code]
Skrobalem to wczoraj wieczorem, wytykanie bledow i konstruktywna krytyka mile widziana, zamieszcze pelne zrodla jak tylko znajde jakis serwer.