Ruby on Rails 2023/11/27 - 會員系統 === 今日重點: - User - sign up - password hashed - salt - sign in - session / cookie - current_user - user_signed_in? # 註冊 ### 建立model ```ternimal rails g model User email password ``` - migration ```ruby # 20231127030550_create_users class CreateUsers < ActiveRecord::Migration[7.1] def change create_table :users do |t| t.string :email t.string :password t.timestamps end add_index :users, :email, unique: true #增加這行 end end ``` 執行`rails db-migrate` ### model驗證 `validates :email, uniqueness:true` - 任何加在model的驗證都不是對資料庫,而是對model - 在migration內做驗證,可以有兩層鎖,model驗證&database驗證 - add_index 加索引,載入速度會變慢,也會建索引,unique:true - model 寫入驗證 ```ruby class User < ApplicationRecord validates :email, presence: true, uniqueness: true validates :password, presence: true, length: {minimum:3, maximum:20} end ``` ### routes 有些是有id 有些是沒id 哪些時候需要**沒有**id? 購物車,會員系統 ### resource or resources? 如果你知道沒有id,resource 就用單數寫法 如果是購物車和user就不需要id() 單數就沒有show ```ruby! #routes.rb resource :users, except: [:destroy] ``` ### 到navbra新增註冊連結 ```ruby <%= link_to "註冊", new_users_path %> ``` ### 利用終端機加controller ``` rails g controller Users ``` - 因為resources是複數,所以此處的Users也是 ### Create - users.controller ``` def new @user = User.new end ``` - new.html.erb - 增加表單小幫手 ```ruby! #new.html.erb <h1 class="text-2xl">註冊帳號</h1> <%= form_with model: @user, data: { turbo: false } do |form| %> <div> <%= form.label :email %> <%= form.email_field :email, class: 'input-field' %> </div> <div> <%= form.label :password %> <%= form.password_field :password, class: 'input-field' %> </div> <div> <%= form.label :password_confirmation %> <%= form.password_field :password_confirmation, class: 'input-field' %> </div> <div> <%= form.submit class: 'ink-btn' %> </div> <% end %> ``` css ``` .input-field { @apply border-2 border-black } ``` ### model增加validates-confirmation 到model ```ruby #users.rb validates :password, presence: true, length: {minimum:3, maximum:20}, confirmation: true ``` - 此時Rails會幫忙增加一個虛擬的表單 new.html.erb加`:password_confirmation` ``` #new.html.erb <div> <%= f.label :password_confirmation%> <%= f.password_field :password_confirmation, class: 'input-field' %> </div> ``` users_controller做一個user_params ```ruby! # users_controller def user_params params.require(:user) .permit(:email, :password, :password_confirmation) end ``` - 因為有寫confirmation,所以會做password_confirmation一個虛擬欄位 把create寫完 ```ruby! def create @user = User.new(user_params) if @user.save redirect_to root_path, notice: '註冊成功' else render :new end end ``` 進去`rails c` 試試看以下指令 ``` irb(main):002> Digest::MD5.hexdigest('123') => "202cb962ac59075b964b07152d234b70" ``` ### 加密(其實是雜湊) - 設定加密(其實是雜湊),為了不想讓人看到密碼長怎麼,不管是系統管理員or駭客 - 在存擋前面,本身這個物件的password,利用Digest::SHA.hexdigest把密碼變成奇怪的樣子,加密的東西放model - 想要在密碼存入前就改變,不要將原始密碼存入資料庫,就可以用before_save ```ruby! #users.rb before_save :encrypt_password #after_save :send_text private def encrypt_password self.password = Digest::SHA1.hexdigest(self.password) end ``` - Salting 在寫進去任意固定位置插入特定的字串,可以想像成灑鹽巴,可以延長被猜出來的機會 在user.rb,`encrypt_password`方法內寫入`self.password = Digest::SHA1.hexdigest(self.password)` ```ruby #users.rb def encrypt_password salted_password = hashed_password = "*xx#{self.paddword}yy-" self.password = Digest::SHA1.hexdigest(self.password) end ``` - 內部專案使用salting沒關係,但是開源專案會有問題 - 其實不能用before_save,因為create,update都會有,所以改用before_create - 使用SHA256,而非SHA1 ```ruby! #users.rb class User < ApplicationRecord validates :email, presence: true, uniqueness: true validates :password, presence: true, length: {minimum:3, maximum:20}, confirmation: true before_create :encrypt_password # before_save :encrypt_password #after_save :send_text private #設定加密,為了不想讓人看到密碼長怎麼,不管是系統管理員or駭客 #在存擋前面,本身這個物件的password,利用Digest::SHA.hexdigest把密碼變成奇怪的樣子 def encrypt_password salted_password = hashed_password = "*xx#{self.paddword}yy-" self.password = Digest::SHA256.hexdigest(salted_password) end end ``` ### 二階段驗證 - 除了密碼之後,還會有簡訊or其他軟體驗證 ## 登入 - session - 登入之後發號碼牌給使用者 - `resource :sessions, only: [:create, :destroy]` - 不需要ID,所以單數就好,需要某些動詞即可,所以使用only - 應該把大部分東西當資源來看,寫法和CRUD一樣 - 使用Create - cookie ```ruby routes.rb get '/users/sign_in', to: 'users#sign_in' ``` - 要去哪裡其實都可以,不是強制,只是有關使用者體驗 - 除了原本的路徑以為,Rails可以幫你擴充其他路徑 ### routes擴充 ```ruby! resources :products do member do get :mm get :download end #寫成member帶有id #會出現/products/:id/mm #需要ID寫在member裏面 collection do get :cc get :special_sell #會出現/products/mm #不需要id可以寫在collection end end ``` ### 幫原本的user擴充路徑 根據上述改寫 ```ruby resource :users, except: [:destroy] do collection do get :sign_in #登入表單 end end ``` ![截圖 2023-11-27 下午2.10.16](https://hackmd.io/_uploads/ByL172bHp.png) ### 加上登入的連結 ```ruby! <%= link_to "登入帳號", sign_in_users_path %> ``` ### form_for vs form_tag - form_for(@product) - 能做到類似form_with的效果,一定要model - 能夠猜是新的還是舊的,新的幫你造、舊的幫你更新 - form_tag(url: 'bmi_calc') - 不需要model - form_with(model: @a) - Rails 5以上才使用 - 有model是form_for,沒有model是form_tag - 能夠整合以上兩種 - @a這個字,和方法定義的有關,ex: @a = User.new - form_with() -> /users/new,因為nil所以默默轉成form_tag ### 製作from表單 ```ruby! # sign_in.html.erb <h1 class="text-2xl">登入帳號</h1> <%= form_with model: @user, data: { turbo: false } do |form| %> <div> <%= form.label :email %> <%= form.email_field :email, class: 'input-field' %> </div> <div> <%= form.label :password %> <%= form.password_field :password, class: 'input-field' %> </div> <div> <%= form.submit '登入', class: 'ink-btn' %> </div> <% end %> ``` 改寫scope,就不用生一個東西出來 ```ruby! # sign_in.html.erb <h1 class="text-2xl">登入帳號</h1> <%= form_with (scope: 'user', data: { turbo: false }) do |form| %> # 這裡改寫 <div> <%= form.label :email %> <%= form.email_field :email, class: 'input-field' %> </div> <div> <%= form.label :password %> <%= form.password_field :password, class: 'input-field' %> </div> <div> <%= form.submit '登入', class: 'ink-btn' %> </div> <% end %> ``` [form_with](https://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with) - 但我們是想要把這個表單送出到session的create,所以自己寫路徑 ### 新增session的routes ```ruby! #routes.rb resource :sessions, only: [:create, :destroy] ``` ### 改寫form,加上 ```ruby! <%= form_with(url: users_sessions_path, scope: 'user', data: {turbo: false}) do |form| %> ``` - 原本就是POST所以不用加 到sessions.controller寫 ```ruby! #sessions.controller user = User.login(param[:user]) if (user) else redirec_to sign_in_users_path, alert: '登入失敗' end ``` ### Fat Model, Thin Controller 把邏輯往model塞,Controller精簡,越簡單越好, 因為model是最容易被重複使用,view也能重複使用,Controller很少重複使用 ### 實體方法 ```ruby! class Cat end kitty = Cat.new def kitty.eat puts kitty.eat puts "hi" end ``` - 單體方法singleton method可以定義實體方法&類別方法 - eat是個實體方法(instance method),作用在`kitty`上 - `Cat.hello`,是類別方法(class method),作用在類別身上的方法 - 在Class內如果要定義類別方法,前面要加self ```ruby! class Cat def slef.hello end end ``` ### 把方法放進去mode ```ruby! #user.rb def self.login(user_params) email = user_params[:email] password = user_params[:password] salted_password = Digest::SHA256.hexdigest(hashed_password = "*xx#{password}yy-") find_by(email: email, password: salted_password) end ``` 可以改寫成 ```ruby! def self.login(user_params) email = user_params[:email] password = Digest::SHA256.hexdigest("*xx#{user_params[:password]}yy-") find_by(email: email, password: password) end ``` 改寫變數 ```ruby def self.login(data) email = data[:email] password = Digest::SHA256.hexdigest("*xx#{data[:password]}yy-") find_by(email: email, password: password) end ``` key和value一樣可以改寫 ```ruby= find_by(email: , password: ) ``` ### 登入成功 & 失敗 ```ruby= sessions_controller.rb if (user) redirect_to sign_in_users_path, notice: '登入成功' else redirect_to sign_in_users_path, alert: '登入失敗' end ``` ### 發號碼牌 - 和flash很像,可以給session[:key] ```ruby= flash[:notice] = '1111' session[:__user_ticket__] = ??? ``` - 可以給個不會重複的東西 ```ruby= if (user) #發號碼牌 session[:__user_ticket__] = user.id redirect_to sign_in_users_path, notice: '登入成功' else redirect_to sign_in_users_path, alert: '登入失敗' end ``` ### session & cookie ? - cookie在server(客人那),ex:客人帶facebook專屬cookie出去 - session在broswer(店家這) - 發號碼牌時後台要有一樣的ticket(就像是領飲料的流程) - session和cookie要對得起來,你有cookie,我有session,我們對在一起才能登入 - 清cookie等於把網站登出,cookie會定期清,cookie過期就對不起來 - session reset的話,cookie號碼牌也撕掉,要重登 - 一般瀏覽器沒事不會把你踢出去 - 忘記裝置 = 清掉session - 每一個request都會有cookie ### 要拿以下這一大串東西 ![截圖 2023-11-27 下午4.24.16](https://hackmd.io/_uploads/rkuBfRZST.png) ### 渲染頁面,讓使用者知道登入了 ```ruby user-id: <%= session[:__users_ticket__]%> ``` ### 製作小幫手 - 因為上面那段太長了,使用/helper來寫 - helper是製作controller時長出來的 - 所有的小幫手可以拿,沒有限制在哪一個頁面都可以,rails啟動時都會長出來 ### helper ``` #Helper module SessionsHelper def current_user session[:__user_ticket__] end end ``` - 載入順序,越下面排序越前面 - 有id了用find找user 用id找使用者 ``` #UsersHelper module UsersHelper def current_user User.find_by(id: session[:__user_ticket__]) end end ``` layout改顯示email #/layouts/application.html.erb ```ruby user-id: <%= current_user.email %> ``` - 選擇自己相信的單位 - 如果改`current_user.password`,就會出現加密後的亂碼 - ?,是否,回傳為boolean - ruby搜尋empty? -> 是不是空的 ### 判斷使用者是否登入 寫在helper ```ruby #usershelper def user_signed_in? current_user.present? end ``` ### navbar改寫 ```ruby <% if user_signed_in? %> <li><%= current_user.email %></li> <li><%= link_to '登出', '#' %></li> <% else %> <li><%= link_to '註冊', new_users_path %></li> <li><%= link_to '登入', sign_in_users_path %></li> <% end %> ``` - 無痕模式為什麼壞掉?因為沒有判斷有沒有登入,把layout的`user-id: <%= current_user.email %>`拿掉 ### 會員套件 bundle add devise $ bundle add devise $ rails g devise:install $ rails g devise User $ rails db:migrate 請參考:[devise](https://github.com/heartcombo/devise) ## 登出 ### 增加登出連結 ```ruby! #navar.html.erb <li><%= link_to '登出', sessions_path, data: {turbo_method:'delete'} %></li> ``` - <a>一般超連結只有get屬性 - 為什麼會有這個效果? Rails會去攔截頁面上的超連結,如果點擊發現有對到他的設定,例如像method,JavaScript就能幫助你做想做的事 在其他框架沒有這個效果 - fetch...then ### 在controller增加destory方法? #sessions_controller.rb ```ruby! def destroy session[@__user_ticket__] == nil redirect_to root_path, notice: '已登出' end ``` - 會遇到:device登出,購物車做在session裡,東西也會跟著清掉 解決辦法:購物車不要放session裡 參考:[rails session](https://www.writesoftwarewell.com/rails-sessions/) - 如果沒有就拿123(邏輯短路造成的回串值) ``` h[:name] || 123 -> 123 h.fetch(:name, 123) -> false ``` - fetch是看key有沒有存在 #### falsy - ruby falsy -> nill & false(只有兩種) - [js falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) -> null, undefined, false, NaN, 0, -0, 0n, "", document.all #### dig [api](https://apidock.com/ruby/Hash/dig) ```ruby irb(main):001> h = {foo: {bar: {baz: 2}}} => {:foo=>{:bar=>{:baz=>2}}} irb(main):002> h.dig(:foo) => {:bar=>{:baz=>2}} ``` ### 應該使用delete方法 ```ruby # sessions_controller.rb def destroy session.delete(:__user_ticket__) redirect_to root_path, notice: '已登出' end ```