trainmaster 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +286 -0
  4. data/Rakefile +38 -0
  5. data/app/controllers/trainmaster/application_controller.rb +9 -0
  6. data/app/controllers/trainmaster/sessions_controller.rb +141 -0
  7. data/app/controllers/trainmaster/users_controller.rb +199 -0
  8. data/app/helpers/trainmaster/application_helper.rb +313 -0
  9. data/app/helpers/trainmaster/sessions_helper.rb +4 -0
  10. data/app/helpers/trainmaster/users_helper.rb +4 -0
  11. data/app/jobs/trainmaster/sessions_cleanup_job.rb +13 -0
  12. data/app/mailers/application_mailer.rb +4 -0
  13. data/app/mailers/trainmaster/user_mailer.rb +14 -0
  14. data/app/models/trainmaster/session.rb +56 -0
  15. data/app/models/trainmaster/user.rb +77 -0
  16. data/app/views/layouts/mailer.html.erb +5 -0
  17. data/app/views/layouts/mailer.text.erb +1 -0
  18. data/app/views/layouts/trainmaster/application.html.erb +14 -0
  19. data/app/views/trainmaster/user_mailer/email_verification.html.erb +12 -0
  20. data/app/views/trainmaster/user_mailer/email_verification.text.erb +13 -0
  21. data/app/views/trainmaster/user_mailer/password_reset.html.erb +14 -0
  22. data/app/views/trainmaster/user_mailer/password_reset.text.erb +15 -0
  23. data/config/routes.rb +10 -0
  24. data/db/migrate/20161120020344_create_trainmaster_users.rb +23 -0
  25. data/db/migrate/20161120020722_create_trainmaster_sessions.rb +11 -0
  26. data/lib/tasks/trainmaster_tasks.rake +4 -0
  27. data/lib/trainmaster.rb +10 -0
  28. data/lib/trainmaster/cache.rb +28 -0
  29. data/lib/trainmaster/engine.rb +9 -0
  30. data/lib/trainmaster/roles.rb +12 -0
  31. data/lib/trainmaster/version.rb +3 -0
  32. data/test/controllers/trainmaster/application_controller_test.rb +106 -0
  33. data/test/controllers/trainmaster/sessions_controller_test.rb +275 -0
  34. data/test/controllers/trainmaster/users_controller_test.rb +335 -0
  35. data/test/dummy/README.rdoc +28 -0
  36. data/test/dummy/Rakefile +6 -0
  37. data/test/dummy/app/assets/javascripts/application.js +13 -0
  38. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  39. data/test/dummy/app/controllers/application_controller.rb +5 -0
  40. data/test/dummy/app/helpers/application_helper.rb +2 -0
  41. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  42. data/test/dummy/bin/bundle +3 -0
  43. data/test/dummy/bin/rails +4 -0
  44. data/test/dummy/bin/rake +4 -0
  45. data/test/dummy/bin/setup +29 -0
  46. data/test/dummy/config.ru +4 -0
  47. data/test/dummy/config/application.rb +34 -0
  48. data/test/dummy/config/boot.rb +5 -0
  49. data/test/dummy/config/database.yml +25 -0
  50. data/test/dummy/config/environment.rb +5 -0
  51. data/test/dummy/config/environments/development.rb +41 -0
  52. data/test/dummy/config/environments/production.rb +79 -0
  53. data/test/dummy/config/environments/test.rb +44 -0
  54. data/test/dummy/config/initializers/assets.rb +11 -0
  55. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  56. data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
  57. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  58. data/test/dummy/config/initializers/inflections.rb +16 -0
  59. data/test/dummy/config/initializers/mime_types.rb +4 -0
  60. data/test/dummy/config/initializers/session_store.rb +3 -0
  61. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  62. data/test/dummy/config/locales/en.yml +23 -0
  63. data/test/dummy/config/routes.rb +4 -0
  64. data/test/dummy/config/secrets.yml +22 -0
  65. data/test/dummy/public/404.html +67 -0
  66. data/test/dummy/public/422.html +67 -0
  67. data/test/dummy/public/500.html +66 -0
  68. data/test/dummy/public/favicon.ico +0 -0
  69. data/test/fixtures/trainmaster/sessions.yml +36 -0
  70. data/test/fixtures/trainmaster/users.yml +27 -0
  71. data/test/integration/navigation_test.rb +10 -0
  72. data/test/jobs/trainmaster/sessions_cleanup_job_test.rb +9 -0
  73. data/test/mailers/previews/trainmaster/user_mailer_preview.rb +6 -0
  74. data/test/mailers/trainmaster/user_mailer_test.rb +9 -0
  75. data/test/models/trainmaster/session_test.rb +26 -0
  76. data/test/models/trainmaster/user_test.rb +52 -0
  77. data/test/test_helper.rb +33 -0
  78. data/test/trainmaster.rb +12 -0
  79. metadata +327 -0
@@ -0,0 +1,199 @@
1
+ require_dependency "trainmaster/application_controller"
2
+
3
+ module Trainmaster
4
+
5
+ ##
6
+ # Users controller that performs CRUD on users.
7
+ #
8
+ class UsersController < ApplicationController
9
+
10
+ # All except user creation requires a session token. Note that reset
11
+ # token is also a legit session token, so :require_token will suffice.
12
+ prepend_before_action :require_auth, only: [:show, :destroy]
13
+ prepend_before_action :accept_auth, only: [:update, :create]
14
+ prepend_before_action :require_admin_auth, only: [:index]
15
+
16
+ # Some actions must have a user specified.
17
+ before_action :get_user, only: [:show, :destroy]
18
+
19
+ ##
20
+ # List all users (but only works for admin user).
21
+ #
22
+ def index
23
+ @users = User.all
24
+ render json: @users, except: [:password_digest]
25
+ end
26
+
27
+ ##
28
+ # Creates a new user. This action does not require any auth although it
29
+ # is optional.
30
+ #
31
+ def create
32
+ logger.debug("Create new user")
33
+ @user = User.new(user_params)
34
+ if @user.save
35
+
36
+ # Save succeeded. Render the response based on the created user.
37
+ render json: @user,
38
+ except: [:verification_token, :reset_token, :password_digest],
39
+ status: 201
40
+
41
+ # Then, issue the verification token and send the email for
42
+ # verification.
43
+ @user.issue_token(:verification_token)
44
+ @user.save
45
+ user_mailer.email_verification(@user).deliver_later
46
+ else
47
+ render_errors 400, @user.errors.full_messages
48
+ end
49
+ end
50
+
51
+ ##
52
+ # Renders a user data.
53
+ #
54
+ def show
55
+ render json: @user, except: [:password_digest]
56
+ end
57
+
58
+ ##
59
+ # Patches the user object. There are four notable operations:
60
+ #
61
+ # - issue reset token
62
+ # - issue verification token
63
+ # - change password
64
+ # - others
65
+ #
66
+ # Issuing either reset token or verification token requires NO
67
+ # authentication. However, for that reason, the request does not get any
68
+ # meaningful response. Instead, an email is sent out for either request.
69
+ #
70
+ # For changing password, there are two ways. One is to use old password
71
+ # and the other is to use reset token.
72
+ #
73
+ # Otherwise, it's a normal update operation.
74
+ #
75
+ def update
76
+ if params[:issue_reset_token] || params[:issue_verification_token]
77
+ # For issuing a reset token, one does not need an auth token. so do
78
+ # not authorize the request. For consistency, we require the id to
79
+ # be "current".
80
+ raise ApplicationController::UNAUTHORIZED_ERROR unless params[:id] == "current"
81
+ get_user_for_token()
82
+ if params[:issue_reset_token]
83
+ update_token(:reset_token)
84
+ else
85
+ update_token(:verification_token)
86
+ end
87
+ else
88
+ get_user()
89
+ allow_password_change? if params[:password]
90
+ update_user(user_params)
91
+ end
92
+ end
93
+
94
+ ##
95
+ # Deletes a user.
96
+ #
97
+ def destroy
98
+ if @user.destroy
99
+ render body: '', status: 204
100
+ else
101
+ # :nocov:
102
+ render_error 400, @user.errors.full_messages
103
+ # :nocov:
104
+ end
105
+ end
106
+
107
+ protected
108
+
109
+ ##
110
+ # Override this method to app specific mailer.
111
+ #
112
+ def user_mailer
113
+ return UserMailer
114
+ end
115
+
116
+ ##
117
+ # Check if password change should be allowed. Two ways to do this: one
118
+ # is to use old password or to use a valid reset token.
119
+ #
120
+ # A ApplicationController::UNAUTHORIZED_ERROR is thrown for invalid old password or
121
+ # invalid reset token
122
+ #
123
+ def allow_password_change?
124
+ if params[:old_password]
125
+ unless @user.authenticate(params[:old_password])
126
+ raise ApplicationController::UNAUTHORIZED_ERROR
127
+ end
128
+ else
129
+ unless @token == @user.reset_token
130
+ raise ApplicationController::UNAUTHORIZED_ERROR
131
+ end
132
+ end
133
+ return true
134
+ end
135
+
136
+ ##
137
+ # This method normally updates the user using permitted params.
138
+ #
139
+ def update_user(update_user_params)
140
+ if @user.update_attributes(update_user_params)
141
+ render json: @user, except: [:password_digest]
142
+ else
143
+ render_errors 400, @user.errors.full_messages
144
+ end
145
+ end
146
+
147
+ ##
148
+ # This method updates user with a new reset token. Only used for this
149
+ # operation.
150
+ #
151
+ def update_token(kind)
152
+ @user.issue_token(kind)
153
+ @user.save
154
+ if kind == :reset_token
155
+ user_mailer.password_reset(@user).deliver_later
156
+ else
157
+ user_mailer.email_verification(@user).deliver_later
158
+ end
159
+ render body: '', status: 204
160
+ end
161
+
162
+ private
163
+
164
+ ##
165
+ # This overrides the application controller's get_user method. Since
166
+ # resource object of this users controller is user, the id is
167
+ # specified in :id param.
168
+ #
169
+ def get_user
170
+ if params[:id] == "current"
171
+ raise ApplicationController::UNAUTHORIZED_ERROR if @auth_user.nil?
172
+ params[:id] = @auth_user.uuid
173
+ end
174
+ @user = find_object(User, params[:id])
175
+ authorize_for!(@user)
176
+ return @user
177
+ end
178
+
179
+ ##
180
+ # For issuing a new reset or for re-issuing a verification token, use
181
+ # this method to get user.
182
+ #
183
+ def get_user_for_token
184
+ @user = User.find_by_username(params[:username])
185
+ raise Repia::Errors::NotFound if @user.nil?
186
+ return @user
187
+ end
188
+
189
+ def user_params
190
+ # Only ADMIN can assign the attribute role. The attribute value will
191
+ # be ignored if the user is not an ADMIN.
192
+ if has_admin_auth?
193
+ params.permit(:username, :password, :password_confirmation, :role, :verified)
194
+ else
195
+ params.permit(:username, :password, :password_confirmation, :verified)
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,313 @@
1
+ module Trainmaster
2
+ module ApplicationHelper
3
+ include Repia::Helper::Base
4
+
5
+ # Respect the config first. If not specified, use 401 Unauthorized.
6
+ UNAUTHORIZED_ERROR = Rails.application.config.try(:unauthorized_error) ||
7
+ Repia::Errors::Unauthorized
8
+
9
+ ##
10
+ # Determines if the authenticated user is admin or not.
11
+ #
12
+ def has_admin_auth?
13
+ return instance_variable_defined?(:@auth_user) &&
14
+ @auth_user.try(:role).try(:>=, Roles::ADMIN)
15
+ end
16
+
17
+ ##
18
+ # Helper method to get the user object in the request, which is
19
+ # specified by :user_id parameter. There are two ways to specify the
20
+ # user id--one in the routing or the auth context.
21
+ #
22
+ # A UNAUTHORIZED_ERROR is raised if the authenticated user is
23
+ # not authorized for the specified user information.
24
+ #
25
+ # A Repia::Errors::NotFound is raised if the specified user cannot
26
+ # be found.
27
+ #
28
+ def get_user(fallback: true)
29
+ user_id = params[:user_id]
30
+ logger.debug("Attempting to get user #{user_id}")
31
+ if !user_id.nil? && user_id != "current"
32
+ @user = find_object(User, params[:user_id]) # will throw error if nil
33
+ authorize_for!(@user)
34
+ elsif fallback || user_id == "current"
35
+ @user = @auth_user
36
+ else
37
+ # :nocov:
38
+ raise Repia::Errors::NotFound, "User #{user_id} does not exist"
39
+ # :nocov:
40
+ end
41
+ end
42
+
43
+ ##
44
+ # :method: require_auth
45
+ #
46
+ # Requires authentication. Either token or api key must be present.
47
+ #
48
+
49
+ ##
50
+ # :method: require_admin_auth
51
+ #
52
+ # Requires admin authentication. Either token or api key of admin must
53
+ # be present.
54
+ #
55
+
56
+ ##
57
+ # :method: accept_auth
58
+ #
59
+ # Accepts authentication if present. Either token or api key is accepted.
60
+ #
61
+
62
+ ##
63
+ # :method: require_token
64
+ #
65
+ # Requires authentication. Token must be present.
66
+ #
67
+
68
+ ##
69
+ # :method: require_admin_token
70
+ #
71
+ # Requires admin authentication. Admin token must # be present.
72
+ #
73
+
74
+ ##
75
+ # :method: accept_token
76
+ #
77
+ # Accepts authentication if present. Only token is accepted.
78
+ #
79
+ ##
80
+ # :method: require_api_key
81
+ #
82
+ # Requires authentication. API key must be present.
83
+ #
84
+
85
+ ##
86
+ # :method: require_admin_api_key
87
+ #
88
+ # Requires admin authentication. Admin api key must be present.
89
+ #
90
+
91
+ ##
92
+ # :method: accept_api_key
93
+ #
94
+ # Accepts authentication if present. Only api key is accepted.
95
+ #
96
+
97
+ #
98
+ # Metaprogramming baby
99
+ #
100
+ ["auth", "token", "api_key"].each do |suffix|
101
+
102
+ define_method "require_#{suffix}" do
103
+ self.method("get_#{suffix}").call
104
+ end
105
+
106
+ define_method "require_admin_#{suffix}" do
107
+ self.method("get_#{suffix}").call(required_role: Roles::ADMIN)
108
+ end
109
+
110
+ define_method "accept_#{suffix}" do
111
+ begin
112
+ self.method("get_#{suffix}").call
113
+ rescue StandardError
114
+ logger.debug("Suppressing error")
115
+ return false
116
+ end
117
+ end
118
+ end
119
+
120
+ ##
121
+ # Determines if the user is authorized for the object. The user must be
122
+ # either the creator of the object or must be an admin or above.
123
+ #
124
+ def authorized_for?(obj)
125
+ logger.debug("Checking to see if authorized to access object")
126
+ if @auth_user.nil?
127
+ # :nocov:
128
+ return false
129
+ # :nocov:
130
+ elsif @auth_user.role >= Roles::ADMIN
131
+ return true
132
+ elsif obj.is_a? User
133
+ return obj == @auth_user
134
+ else
135
+ return obj.try(:user) == @auth_user
136
+ end
137
+ end
138
+ alias_method :authorize_for?, :authorized_for?
139
+
140
+ ##
141
+ # Deprecated: use authorized_for? instead.
142
+ #
143
+ def authorized?(obj); authorized_for?(obj) end
144
+
145
+ ##
146
+ # Authorize the user for a specified object. If the user does not have
147
+ # permission, it will throw an exception. Note that it is sometimes not
148
+ # desirable to provide detailed information about authorization failure.
149
+ # Note that this will not include this detail in the exception.
150
+ #
151
+ # A UNAUTHORIZED_ERROR is raised.
152
+ #
153
+ def authorize_for!(obj)
154
+ if !authorized_for?(obj)
155
+ logger.error("User #{@auth_user.uuid} does not have permission " +
156
+ "to access #{obj}")
157
+ raise UNAUTHORIZED_ERROR, "User is not authorized"
158
+ end
159
+ end
160
+
161
+ protected
162
+
163
+ ##
164
+ # Attempts to retrieve the payload encoded in the token. It checks if
165
+ # the token is "valid" according to JWT definition and not expired.
166
+ #
167
+ # A UNAUTHORIZED_ERROR is raised if token cannot be decoded.
168
+ #
169
+ def get_token_payload(token)
170
+
171
+ # Attempt to decode without verifying. May raise DecodeError.
172
+ decoded = JWT.decode token, nil, false
173
+ payload = decoded[0]
174
+
175
+ # At this point, we know that the token is not expired and
176
+ # well formatted. Find out if the payload is well defined.
177
+ if payload.nil?
178
+ # :nocov:
179
+ logger.error("Token payload is nil: #{token}")
180
+ raise UNAUTHORIZED_ERROR, "Invalid token"
181
+ # :nocov:
182
+ end
183
+
184
+ return payload
185
+
186
+ rescue JWT::DecodeError => e
187
+ logger.error("Token decode error: #{e.message}")
188
+ raise UNAUTHORIZED_ERROR, "Invalid token"
189
+ end
190
+
191
+ ##
192
+ # Truly verifies the token and its payload. It ensures the user and
193
+ # session specified in the token payload are indeed valid. The
194
+ # required role is also checked.
195
+ #
196
+ # A UNAUTHORIZED_ERROR is thrown for all cases where token is
197
+ # invalid.
198
+ #
199
+ def verify_token(token)
200
+ logger.debug("Verifying token: #{token}")
201
+
202
+ # First get the payload of the token. This will also verify whether
203
+ # or not the token is welformed.
204
+ payload = get_token_payload(token)
205
+
206
+ # Next, the payload should define user UUID and session UUID.
207
+ user_uuid = payload["user_uuid"]
208
+ session_uuid = payload["session_uuid"]
209
+ if user_uuid.nil? || session_uuid.nil?
210
+ logger.error("User or session is not specified")
211
+ raise UNAUTHORIZED_ERROR, "Invalid token"
212
+ end
213
+ logger.debug("Token well defined: #{token}")
214
+
215
+ # But, the user UUID and session UUID better be valid too. That is,
216
+ # they must be real user and session, and the session must belong to
217
+ # the user.
218
+ auth_user = User.find_by_uuid(user_uuid)
219
+ if auth_user.nil?
220
+ # :nocov:
221
+ logger.error("Specified user doesn't exist #{user_uuid}")
222
+ raise UNAUTHORIZED_ERROR, "Invalid token"
223
+ # :nocov:
224
+ end
225
+ auth_session = Session.find_by_uuid(session_uuid)
226
+ if auth_session.nil? || auth_session.user != auth_user
227
+ logger.error("Specified session doesn't exist #{session_uuid}")
228
+ raise UNAUTHORIZED_ERROR, "Invalid token"
229
+ end
230
+
231
+ # Finally, decode the token using the secret. Also check expiration
232
+ # here too.
233
+ JWT.decode token, auth_session.secret, true
234
+ logger.debug("Token well formatted and verified. Set cache.")
235
+
236
+ # Return the corresponding session
237
+ return auth_session
238
+
239
+ rescue JWT::DecodeError => e
240
+ logger.error(e.message)
241
+ raise UNAUTHORIZED_ERROR, "Invalid token"
242
+ end
243
+
244
+ ##
245
+ # Attempt to get a token for the session. Token must be specified in
246
+ # query string or part of the JSON object.
247
+ #
248
+ # Raises a UNAUTHORIZED_ERROR if cached session has less role
249
+ # than what's required.
250
+ #
251
+ def get_token(required_role: Roles::PUBLIC)
252
+ token = params[:token]
253
+
254
+ # Look up the cache. If present, use it and skip the verification.
255
+ # Use token itself (and not a session UUID) as part of the key so
256
+ # it can be considered *verified*.
257
+ @auth_session = Cache.get(kind: :session, token: token)
258
+
259
+ # Cache miss. So proceed to verify the token and get user and
260
+ # session data from database. Then set the cache for later.
261
+ if @auth_session.nil?
262
+ @auth_session = verify_token(token)
263
+ @auth_session.role # NOTE: no-op
264
+ Cache.set({kind: :session, token: token}, @auth_session)
265
+ end
266
+
267
+ # Obtained session may not have enough permission. Check here.
268
+ if @auth_session.role < required_role
269
+ logger.error("Not enough permission (role: #{@auth_session.role})")
270
+ raise UNAUTHORIZED_ERROR, "Invalid token"
271
+ end
272
+ @auth_user = @auth_session.user
273
+ @token = @auth_session.token
274
+ return true
275
+ end
276
+
277
+ ##
278
+ # Get API key from the request.
279
+ #
280
+ # Raises a UNAUTHORIZED_ERROR if API key is not valid (or not
281
+ # provided).
282
+ #
283
+ def get_api_key(required_role: Roles::PUBLIC)
284
+ api_key = params[:api_key]
285
+ if api_key.nil?
286
+ # This case is not likely, but as a safeguard in case migration
287
+ # has not gone well.
288
+ # :nocov:
289
+ raise UNAUTHORIZED_ERROR, "Invalid api key"
290
+ # :nocov:
291
+ end
292
+ auth_user = User.find_by_api_key(api_key)
293
+ if auth_user.nil? || auth_user.role < required_role
294
+ raise UNAUTHORIZED_ERROR, "Invalid api key"
295
+ end
296
+ @auth_user = auth_user
297
+ @auth_session = nil
298
+ @token = nil
299
+ return true
300
+ end
301
+
302
+ ##
303
+ # Get auth data from the request. The token takes the precedence.
304
+ #
305
+ def get_auth(required_role: Roles::USER)
306
+ if params[:token]
307
+ get_token(required_role: required_role)
308
+ else
309
+ get_api_key(required_role: required_role)
310
+ end
311
+ end
312
+ end
313
+ end