authorio 0.8.1 → 0.8.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -0
  3. data/app/assets/stylesheets/authorio/auth.css +55 -1
  4. data/app/controllers/authorio/auth_controller.rb +69 -102
  5. data/app/controllers/authorio/authorio_controller.rb +78 -0
  6. data/app/controllers/authorio/sessions_controller.rb +33 -0
  7. data/app/controllers/authorio/users_controller.rb +34 -0
  8. data/app/helpers/authorio/tag_helper.rb +17 -0
  9. data/app/jobs/authorio/application_job.rb +2 -0
  10. data/app/models/authorio/application_record.rb +2 -0
  11. data/app/models/authorio/request.rb +48 -1
  12. data/app/models/authorio/session.rb +43 -0
  13. data/app/models/authorio/token.rb +23 -1
  14. data/app/models/authorio/user.rb +14 -0
  15. data/app/views/authorio/auth/authorization_interface.html.erb +14 -35
  16. data/app/views/authorio/auth/issue_token.json.jbuilder +7 -0
  17. data/app/views/authorio/auth/send_profile.json.jbuilder +3 -0
  18. data/app/views/authorio/auth/verify_token.json.jbuilder +5 -0
  19. data/app/views/authorio/sessions/new.html.erb +14 -0
  20. data/app/views/authorio/users/_profile.json.jbuilder +10 -0
  21. data/app/views/authorio/users/edit.html.erb +25 -0
  22. data/app/views/authorio/users/show.html.erb +18 -0
  23. data/app/views/authorio/users/verify.html.erb +1 -0
  24. data/app/views/layouts/authorio/main.html.erb +38 -0
  25. data/app/views/shared/_login_form.html.erb +41 -0
  26. data/config/routes.rb +15 -5
  27. data/db/migrate/20210723161041_add_expiry_to_tokens.rb +5 -0
  28. data/db/migrate/20210726164625_create_authorio_sessions.rb +12 -0
  29. data/db/migrate/20210801184120_add_profile_to_users.rb +8 -0
  30. data/db/migrate/20210817010101_change_path_to_username_in_users.rb +7 -0
  31. data/db/migrate/20210831155106_add_code_challenge_to_requests.rb +5 -0
  32. data/lib/authorio/configuration.rb +14 -9
  33. data/lib/authorio/engine.rb +11 -8
  34. data/lib/authorio/exceptions.rb +20 -3
  35. data/lib/authorio/routes.rb +10 -7
  36. data/lib/authorio/version.rb +3 -1
  37. data/lib/authorio.rb +15 -21
  38. data/lib/generators/authorio/install/install_generator.rb +3 -3
  39. data/lib/generators/authorio/install/templates/authorio.rb +22 -8
  40. data/lib/tasks/authorio_tasks.rake +15 -14
  41. metadata +49 -20
  42. data/app/controllers/authorio/application_controller.rb +0 -4
  43. data/app/controllers/authorio/helpers.rb +0 -17
  44. data/app/helpers/authorio/application_helper.rb +0 -4
  45. data/app/helpers/authorio/test_helper.rb +0 -4
  46. data/app/views/layouts/authorio/application.html.erb +0 -15
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authorio
4
+ # These helpers are provided to the main application
5
+ module TagHelper
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ helper_method :indieauth_tag if respond_to?(:helper_method)
10
+ end
11
+
12
+ def indieauth_tag
13
+ tag(:link, rel: 'authorization_endpoint', href: URI.join(main_app.root_url, Authorio.authorization_path)) <<
14
+ tag(:link, rel: 'token_endpoint', href: URI.join(main_app.root_url, Authorio.token_path))
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class ApplicationJob < ActiveJob::Base
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class ApplicationRecord < ActiveRecord::Base
3
5
  self.abstract_class = true
@@ -1,7 +1,54 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class Request < ApplicationRecord
3
- belongs_to :authorio_user, class_name: "::Authorio::User"
5
+ belongs_to :authorio_user, class_name: '::Authorio::User'
4
6
 
5
7
  validates_presence_of :code, :redirect_uri, :client
8
+
9
+ before_validation :set_code, on: :create
10
+ before_create :sweep_requests
11
+
12
+ # The IndieAuth spec uses 'client_id' to specify the client in the address, as a URL (eg "https://example.com")
13
+ # But Rails uses '_id' to tag associations (foreign keys). So we save that as 'client' here, but map
14
+ # client_id as an alias since that is what the HTTP parameter will be
15
+ def client_id=(value)
16
+ self.client = value
17
+ end
18
+
19
+ def validate_oauth(params)
20
+ redirect_uri == params[:redirect_uri] &&
21
+ client == params[:client_id] &&
22
+ created_at > 10.minutes.ago &&
23
+ code_challenge_matches(params[:code_verifier]) &&
24
+ self
25
+ end
26
+
27
+ def code_challenge_matches(verifier)
28
+ # For now, if original request did not have code challenge, then we pass by default
29
+ return true if code_challenge.blank?
30
+
31
+ sha256 = Digest::SHA256.digest verifier
32
+ Base64.urlsafe_encode64(sha256).sub(/=*$/, '') == code_challenge
33
+ end
34
+
35
+ def self.user_scope_description(scope)
36
+ USER_SCOPE_DESCRIPTION[scope.to_sym] || scope
37
+ end
38
+
39
+ private
40
+
41
+ def set_code
42
+ self.code = SecureRandom.hex(20)
43
+ end
44
+
45
+ def sweep_requests
46
+ Request.where(client: client, authorio_user: authorio_user).destroy_all
47
+ end
48
+
49
+ USER_SCOPE_DESCRIPTION = {
50
+ profile: 'View basic profile information',
51
+ email: 'View your email address'
52
+ }.freeze
6
53
  end
7
54
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authorio
4
+ class Session < ApplicationRecord
5
+ # Implement a session cookie store based on best security practices
6
+ # See: https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence
7
+ belongs_to :authorio_user, class_name: '::Authorio::User'
8
+
9
+ # 1. Protect against having database stolen by only storing token hashes
10
+ attribute :token # This will not be persisted in the DB
11
+ has_secure_token
12
+
13
+ before_create do
14
+ self.expires_at = Time.now + Authorio.configuration.token_expiration
15
+ self.selector = SecureRandom.hex(12)
16
+ self.hashed_token = Digest::SHA256.hexdigest token
17
+ end
18
+
19
+ # 2. To guard against timing attacks, we lookup tokens based on a separate selector attribute
20
+ # and compare them using a secure time-constant comparison method
21
+ def self.find_by_cookie(cookie)
22
+ selector, _token = cookie.split(':')
23
+ session = find_by selector: selector
24
+ raise Authorio::Exceptions::SessionReplayAttack.new, session unless session.matches_cookie?(cookie)
25
+
26
+ session
27
+ end
28
+
29
+ def matches_cookie?(cookie)
30
+ _selector, token = cookie.split(':')
31
+ cookie_hashed_token = Digest::SHA256.hexdigest token
32
+ !expired? && ActiveSupport::SecurityUtils.secure_compare(cookie_hashed_token, hashed_token)
33
+ end
34
+
35
+ def expired?
36
+ expires_at < Time.now
37
+ end
38
+
39
+ def as_cookie
40
+ "#{selector}:#{token}"
41
+ end
42
+ end
43
+ end
@@ -1,8 +1,30 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class Token < ApplicationRecord
3
- belongs_to :authorio_user, class_name: "::Authorio::User"
5
+ belongs_to :authorio_user, class_name: '::Authorio::User'
4
6
  has_secure_token :auth_token
5
7
 
6
8
  validates_presence_of :scope, :client
9
+
10
+ before_create do
11
+ self.expires_at = Time.now + Authorio.configuration.token_expiration
12
+ end
13
+
14
+ # The token endpoint can get hit by bots, so short-circut the find if they
15
+ # don't send a bearer token
16
+ def self.find_by_auth_token(token)
17
+ token and find_by auth_token: token
18
+ end
19
+
20
+ def expired?
21
+ expires_at < Time.now
22
+ end
23
+
24
+ def self.create_from_request(req)
25
+ raise Exceptions::InvalidGrant, 'missing scope' if req.scope.blank?
26
+
27
+ Token.create(authorio_user: req.authorio_user, scope: req.scope, client: req.client)
28
+ end
7
29
  end
8
30
  end
@@ -1,5 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class User < ApplicationRecord
3
5
  has_secure_password
6
+
7
+ class << self
8
+ def find_by_url!(url)
9
+ find_by_username!(URI(url).path)
10
+ end
11
+
12
+ def find_by_username!(name)
13
+ return first unless Authorio.configuration.multiuser
14
+
15
+ find_by(username: name) or raise Exceptions::UserNotFound
16
+ end
17
+ end
4
18
  end
5
19
  end
@@ -1,37 +1,16 @@
1
- <!DOCTYPE html>
2
- <html lang="en-GB">
3
- <head>
4
- <meta charset="utf-8">
5
- <title>Authorio Login</title>
6
- <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <link rel="stylesheet"
8
- href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
9
- integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
10
- crossorigin="anonymous">
11
- <%= stylesheet_link_tag "authorio/auth" %>
12
- </head>
13
- <body>
14
- <div class="container authorio-auth">
15
- <div class="row">
16
- <div class="col-md-4">
17
- </div>
18
- <div class="col-md-4 auth-panel">
19
- <h3>Authorio</h3>
20
- <div class="client-row">
21
- Authenticating with <span class="client"><%= params[:client_id] %>
22
- </div>
23
- <%= form_with(url: "authorize_user", method: :post) do |form| %>
24
- <%= form.label(:url, "User URL") %>
25
- <%= form.text_field(:url, value: @user_url, readonly: true) %>
26
- <%= form.label(:password, "Password") %>
27
- <%= form.password_field(:password, autofocus: true) %>
28
- <%= form.hidden_field(:client, value: params[:client_id]) %>
29
- <%= form.submit("Sign in", class: 'btn btn-success') %>
30
- <% end %>
31
- </div>
32
- <div class="col-md-4">
33
- </div>
1
+ <% content_for :title, "Authorio Login" %>
2
+
3
+ <div class="container authorio-auth">
4
+ <div class="row">
5
+ <div class="col-md-4"></div>
6
+ <div class="col-md-4 auth-panel">
7
+ <h3>Authorio</h3>
8
+ <div class="client-row">
9
+ <span class="client"><%= params[:client_id] %></span> wants to authenticate
10
+ <% if params[:scope] %>and also<% end %>
34
11
  </div>
12
+ <%= render 'shared/login_form', target: authorize_user_path, cancel: true %>
35
13
  </div>
36
- </body>
37
- </html>
14
+ <div class="col-md-4"></div>
15
+ </div>
16
+ </div>
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.access_token @token.auth_token
4
+ json.expires_in Authorio.configuration.token_expiration
5
+ json.token_type 'Bearer'
6
+ json.scope @token.scope
7
+ json.partial! 'authorio/users/profile', request: @auth_request
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.partial! 'authorio/users/profile', request: @request
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.me url_for(@token.authorio_user)
4
+ json.client_id @token.client
5
+ json.scope @token.scope
@@ -0,0 +1,14 @@
1
+ <%= stylesheet_link_tag "authorio/auth" %>
2
+ <% content_for :title, "Authorio Local Login" %>
3
+
4
+ <div class="container authorio-auth">
5
+ <div class="row">
6
+ <div class="col-md-4"></div>
7
+ <div class="col-md-4 auth-panel">
8
+ <h3>Authorio</h3>
9
+ <div class="client-row">Local Login</div>
10
+ <%= render 'shared/login_form', target: session_path(@session), cancel: false %>
11
+ </div>
12
+ <div class="col-md-4"></div>
13
+ </div>
14
+ </div>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.me profile_url(@auth_request.authorio_user)
4
+ if @auth_request.scope&.include? 'profile'
5
+ json.profile do
6
+ json.name(@auth_request.authorio_user.full_name)
7
+ json.call(@auth_request.authorio_user, :url, :photo)
8
+ json.email(@auth_request.authorio_user.email) if @auth_request.scope.include?('email')
9
+ end
10
+ end
@@ -0,0 +1,25 @@
1
+ <%= stylesheet_link_tag "authorio/auth" %>
2
+ <% content_for :title, "Account Settings" %>
3
+
4
+ <div class="container authorio-auth">
5
+ <div class="row">
6
+ <div class="col-md-4"></div>
7
+ <div class="col-md-4 auth-panel">
8
+ <h3>Account Settings</h3>
9
+ <%= form_with model: @user do |form| %>
10
+ <%= form.label(:full_name, "Full Name") %>
11
+ <%= form.text_field(:full_name) %>
12
+ <%= form.label(:url, "URL") %>
13
+ <%= form.text_field(:url) %>
14
+ <%= form.label(:photo, "Photo URL") %>
15
+ <%= form.text_field(:photo) %>
16
+ <%= form.label(:email, "Email") %>
17
+ <%= form.text_field(:email) %>
18
+ <div class='auth-btn-row'>
19
+ <%= form.submit("Save Changes", class: 'btn btn-success auth-btn') %>
20
+ </div>
21
+ <% end -%>
22
+ </div>
23
+ <div class="col-md-4"></div>
24
+ </div>
25
+ </div>
@@ -0,0 +1,18 @@
1
+ <%= stylesheet_link_tag "authorio/auth" %>
2
+ <% content_for :title, "User Account" %>
3
+
4
+ <%= indieauth_tag %>
5
+
6
+ <div class="container authorio-auth">
7
+ <div class="row">
8
+ <div class="col-md-4"></div>
9
+ <div class="col-md-4 auth-panel">
10
+ <h3>IndieAuth Account</h3>
11
+ Full Name <%= @user.full_name %>
12
+ URL <%= @user.url %>
13
+ Photo <%= image_tag @user.photo %>
14
+ Email <%= @user.email %>
15
+ </div>
16
+ <div class="col-md-4"></div>
17
+ </div>
18
+ </div>
@@ -0,0 +1 @@
1
+ <%= indieauth_tag %>
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= yield(:title) %></title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <meta charset="utf-8">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+ <link rel="stylesheet"
10
+ href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
11
+ integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
12
+ crossorigin="anonymous">
13
+
14
+ <%= stylesheet_link_tag "authorio/application", media: "all" %>
15
+ <%= stylesheet_link_tag "authorio/auth" %>
16
+ </head>
17
+ <body>
18
+
19
+ <% if logged_in? %>
20
+ <div class="topbar">
21
+ <ul>
22
+ <li>Authorio</li>
23
+ <li><a href="<%= edit_user_path(current_user) -%>">Account Settings</a></li>
24
+ <li><a href="<%= logout_path(method: :delete) -%>">Log Out</a></li>
25
+ </ul>
26
+ </div>
27
+ <% end -%>
28
+
29
+ <% flash.each do |key, value| %>
30
+ <div class="alert alert-warning">
31
+ <p><%= value %></p>
32
+ </div>
33
+ <% end %>
34
+
35
+ <%= yield %>
36
+
37
+ </body>
38
+ </html>
@@ -0,0 +1,41 @@
1
+ <%= form_with(url: target, method: :post) do |form| %>
2
+ <% if params[:scope] %>
3
+ <%= fields_for :scope do |req_scope| %>
4
+ <div class="scopes">
5
+ <ul class="scope">
6
+ <% for scope in params[:scope].split %>
7
+ <li>
8
+ <%= label_tag(:scope, class: 'scope-label') do %>
9
+ <%= req_scope.check_box(:scope, {multiple: true, checked: true}, scope, nil) %>
10
+ <%= user_scope_description scope %>
11
+ <% end -%>
12
+ </li>
13
+ <%- end %>
14
+ </ul>
15
+ </div>
16
+ <% end %>
17
+ <% end -%>
18
+ <%= fields_for :user do |user_scope| %>
19
+ <%= user_scope.hidden_field :dummy, value: 42 %>
20
+ <% if Authorio.configuration.multiuser %>
21
+ <%= user_scope.label(:username, "Username") %>
22
+ <%= user_scope.text_field(:username) %>
23
+ <% end -%>
24
+ <% unless logged_in? %>
25
+ <%= user_scope.label(:password, "Password") %>
26
+ <%= user_scope.password_field(:password, autofocus: true) %>
27
+ <% if rememberable? %>
28
+ <%= label_tag(:remember_me, class: 'remember') do %>
29
+ <%= user_scope.check_box :remember_me %>
30
+ <span class='r-m'>Remember me for <%= distance_of_time_in_words Authorio.configuration.local_session_lifetime -%></span>
31
+ <% end %>
32
+ <% end %>
33
+ <% end %>
34
+ <% end -%>
35
+ <div class='auth-btn-row'>
36
+ <%= form.submit("Sign in", class: 'btn btn-success auth-btn') %>
37
+ <% if cancel %>
38
+ <%= form.submit("Cancel", class: 'btn btn-default auth-btn') %>
39
+ <% end %>
40
+ </div>
41
+ <% end %>
data/config/routes.rb CHANGED
@@ -1,7 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  Authorio::Engine.routes.draw do
2
- get Authorio.configuration.authorization_endpoint, controller: 'auth', action: 'authorization_interface'
3
- post Authorio.configuration.authorization_endpoint, controller: 'auth', action: 'send_profile'
4
- post '/authorize_user', controller: 'auth', action: 'authorize_user'
5
- get Authorio.configuration.token_endpoint, controller: 'auth', action: 'verify_token'
6
- post Authorio.configuration.token_endpoint, controller: 'auth', action: 'issue_token'
4
+ root to: 'authorio#index'
5
+
6
+ get Authorio.configuration.authorization_endpoint, controller: 'auth', action: 'authorization_interface'
7
+ resources :users, only: %i[show edit update]
8
+ post 'user/authorize', to: 'auth#authorize_user', as: 'authorize_user'
9
+ resource :session, only: %i[new create]
10
+ get 'session', to: 'sessions#destroy', as: 'logout'
11
+ get 'user/(:id)/verify', to: 'users#verify', as: 'verify_user'
12
+ defaults format: :json do
13
+ post Authorio.configuration.authorization_endpoint, controller: 'auth', action: 'send_profile'
14
+ get Authorio.configuration.token_endpoint, controller: 'auth', action: 'verify_token'
15
+ post Authorio.configuration.token_endpoint, controller: 'auth', action: 'issue_token'
16
+ end
7
17
  end